diff --git a/.github/renovate.json5 b/.github/renovate.json5 index be8bde70c3..85377d905c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -13,18 +13,12 @@ "hostRules": [ { "matchHost": "https://repo1.maven.org/maven2/", - "concurrentRequestLimit": 4, - "maxRequestsPerSecond": 4, + "concurrentRequestLimit": 10, + "maxRequestsPerSecond": 10, + "timeout": 10000, } ], "packageRules": [ - { - "matchPackageNames": [ - "/^androidx.\.*/" - ], - "groupName": "androidx", - "registryUrls": [ "https://dl.google.com/android/maven2/" ], - }, { "matchPackageNames": [ "org.matrix.rustcomponents:sdk-android", @@ -33,6 +27,18 @@ "minimumReleaseAge": null, // Max default value is 6, with this we give absolute priority to our own dependencies "prPriority": 7, + "registryUrls": [ "https://repo1.maven.org/maven2/" ], + }, + { + // Fetch Android dependencies from Google's Maven repo instead of Maven Central. + "matchPackageNames": [ + "/^androidx\\./", + "/^android\\./", + "/^com\\.android\\.tools/", + "/^com\\.google\\.firebase/", + "/^com\\.google\\.android/", + ], + "registryUrls": [ "https://dl.google.com/android/maven2/" ], }, { "groupName": "kotlin", diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml deleted file mode 100644 index bf1a302645..0000000000 --- a/.github/workflows/danger.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Danger CI - -on: [pull_request, merge_group] - -permissions: {} - -jobs: - build: - runs-on: ubuntu-latest - name: Danger main check - # Skip in forks, it doesn't work even with the fallback token - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Add SSH private keys for submodule repositories - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 - with: - ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} - - name: Clone submodules - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} - run: git submodule update --init --recursive - - run: | - npm install --save-dev @babel/plugin-transform-flow-strip-types - - name: Danger - uses: danger/danger-js@a236e3b8c41f0babbf133568db851a20a34f9a7b # 14.0.5 - with: - args: "--dangerfile ./tools/danger/dangerfile.js" - env: - DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} - # Fallback for forks - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000000..175cf43509 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,126 @@ +name: PR Checks + +on: + # Privilege escalation necessary to comment on pull requests from forks. + # 🚨 We must not check out or execute any code here, and be careful around use of user-controlled inputs. + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [ opened, reopened, edited, synchronize, labeled, unlabeled ] + +permissions: {} + +jobs: + pr-checks: + name: PR Checks + runs-on: ubuntu-latest + timeout-minutes: 5 + + concurrency: + # Only allow a single run of this workflow on each branch, automatically cancelling older runs. + group: ${{ format('pr-checks-{0}', github.ref) }} + cancel-in-progress: true + + permissions: + # Required to comment on the pull request. + pull-requests: write + + steps: + - name: Check the pull request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + // Classes that must not be modified anymore. Add the file name suffix here to freeze a class. + const FROZEN_CLASSES = [ + ]; + + // Only this user is allowed to edit the translation files, since they come from Localazy. + const TRANSLATION_ALLOW_LIST = ["ElementBot"]; + + const pr = context.payload.pull_request; + const body = pr.body ?? ""; + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + // Deleted files are not interesting for any of the checks below. + const paths = files.filter(file => file.status !== "removed").map(file => file.filename); + + const warnings = []; + const failures = []; + + if (!body) { + warnings.push("Please provide a description for this PR."); + } + + if (paths.length > 50) { + warnings.push("This pull request seems relatively large. Please consider splitting it into multiple smaller ones."); + } + + if (pr.title.endsWith("…")) { + failures.push("Please provide a complete title that can be used as a changelog entry."); + } + + if (pr.labels.filter(label => label.name.startsWith("PR-")).length !== 1) { + failures.push("Please add a single `PR-` label to categorise the changelog entry."); + } + + for (const frozen of FROZEN_CLASSES) { + if (paths.some(path => path.endsWith(frozen))) { + failures.push(`Frozen class \`${frozen}\` has been modified. Please do not modify frozen class.`); + } + } + + // Exclude the recorded screenshots, which are legitimately pngs. + if (paths.some(path => path.toLowerCase().endsWith(".png") && !path.includes("snapshots/images/"))) { + warnings.push("You seem to have made changes to some images. Please consider using a vector drawable."); + } + + if (!TRANSLATION_ALLOW_LIST.includes(pr.user.login) && paths.some(path => path.endsWith("translations.xml"))) { + failures.push("Some translation files have been edited. Only user `ElementBot` (i.e. translations coming from Localazy) is allowed to do that. " + + "Please read more about translations management [in the doc](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#strings)."); + } + + warnings.forEach(warning => core.warning(warning)); + failures.forEach(failure => core.error(failure)); + + // Keep a single comment up to date rather than adding a new one on every run. + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + const existing = comments.find(comment => comment.body.includes(marker)); + + if (warnings.length || failures.length) { + const lines = [...failures.map(failure => `- ❌ ${failure}`), + ...warnings.map(warning => `- ⚠️ ${warning}`)]; + const commentBody = `${marker}\n### PR Checks\n\n${lines.join("\n")}`; + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: commentBody + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + } + } else if (existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id + }); + } + + if (failures.length) { + core.setFailed(`${failures.length} check(s) failed, see the comment on the pull request.`); + } diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 82d2375058..5f0c2b1b3a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -342,28 +342,166 @@ jobs: name: Project Check Suite runs-on: ubuntu-latest needs: [konsist, lint, ktlint, detekt] - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} + # Run even if a previous job failed: that is precisely when there are issues to report. + if: ${{ !cancelled() }} + permissions: + # Required to list the files modified by the pull request. + pull-requests: read steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # Ensure we are building the branch and not the branch after being merged on develop - # https://github.com/actions/checkout/issues/881 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - persist-credentials: false - name: Download reports from previous jobs uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - - name: Prepare Danger - if: always() - run: | - npm install --save-dev @babel/core - npm install --save-dev @babel/plugin-transform-flow-strip-types - yarn add danger-plugin-lint-report --dev - - name: Danger lint - if: always() - uses: danger/danger-js@a236e3b8c41f0babbf133568db851a20a34f9a7b # 14.0.5 + - name: Annotate the lint, ktlint and detekt issues + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - args: "--dangerfile ./tools/danger/dangerfile-lint.js" - env: - DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} - # Fallback for forks - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + script: | + // GitHub only surfaces a handful of annotations per level and per step, so cap what we emit + // and let the job summary carry the full list. + const MAX_ANNOTATIONS = 20; + const MAX_SUMMARY_ROWS = 200; + + function unescapeXml(value) { + return value + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/&/g, "&"); + } + + function attribute(tag, name) { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(tag); + return match ? unescapeXml(match[1]) : undefined; + } + + // Paths in the reports are absolute paths on the runner which generated them, i.e. + // /home/runner/work///. + const workspace = `/work/${context.repo.repo}/${context.repo.repo}/`; + function repoRelative(path) { + const index = path.indexOf(workspace); + return index === -1 ? path : path.slice(index + workspace.length); + } + + // Checkstyle XML, as produced by ktlint and detekt: + // + function parseCheckstyle(content) { + const issues = []; + const fileRegex = /]*?)(\/>|>([\s\S]*?)<\/file>)/g; + let file; + while ((file = fileRegex.exec(content)) !== null) { + const path = attribute(file[1], "name"); + if (!path) continue; + const errorRegex = /]*?)\/?>/g; + let error; + while ((error = errorRegex.exec(file[3] ?? "")) !== null) { + issues.push({ + file: repoRelative(path), + line: Number(attribute(error[1], "line")) || 1, + column: Number(attribute(error[1], "column")) || undefined, + severity: (attribute(error[1], "severity") ?? "warning").toLowerCase(), + rule: attribute(error[1], "source"), + message: attribute(error[1], "message") ?? "", + }); + } + } + return issues; + } + + // Android lint XML, which is not Checkstyle: + // + function parseAndroidLint(content) { + const issues = []; + const issueRegex = /]*?)(\/>|>([\s\S]*?)<\/issue>)/g; + let issue; + while ((issue = issueRegex.exec(content)) !== null) { + const location = /]*?)\/?>/.exec(issue[3] ?? ""); + if (!location) continue; + const path = attribute(location[1], "file"); + if (!path) continue; + issues.push({ + file: repoRelative(path), + line: Number(attribute(location[1], "line")) || 1, + column: Number(attribute(location[1], "column")) || undefined, + severity: (attribute(issue[1], "severity") ?? "warning").toLowerCase(), + rule: attribute(issue[1], "id"), + message: attribute(issue[1], "message") ?? "", + }); + } + return issues; + } + + const reports = await (await glob.create("**/reports/**/*.xml")).glob(); + core.info(`Found ${reports.length} XML report(s).`); + + const fs = require("fs"); + let issues = []; + for (const report of reports) { + const content = fs.readFileSync(report, "utf8"); + if (content.includes(" file.filename)); + issues = issues.filter(issue => changed.has(issue.file)); + } + + const seen = new Set(); + issues = issues.filter(issue => { + const key = `${issue.file}:${issue.line}:${issue.rule}:${issue.message}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + if (issues.length === 0) { + core.info("No issue found in the files modified by this pull request."); + return; + } + + const level = severity => { + if (severity === "error" || severity === "fatal") return "error"; + if (severity === "warning") return "warning"; + return "notice"; + }; + const rank = { error: 0, warning: 1, notice: 2 }; + issues.sort((a, b) => + rank[level(a.severity)] - rank[level(b.severity)] || + a.file.localeCompare(b.file) || + a.line - b.line + ); + + issues.slice(0, MAX_ANNOTATIONS).forEach(issue => { + const annotation = { + title: issue.rule, + file: issue.file, + startLine: issue.line, + startColumn: issue.column, + }; + core[level(issue.severity)](issue.message, annotation); + }); + + core.summary.addHeading(`${issues.length} code quality issue(s)`, 3); + core.summary.addTable([ + [{ data: "Severity", header: true }, { data: "File", header: true }, { data: "Rule", header: true }, { data: "Message", header: true }], + ...issues.slice(0, MAX_SUMMARY_ROWS).map(issue => [ + level(issue.severity), + `${issue.file}:${issue.line}`, + issue.rule ?? "", + issue.message, + ]), + ]); + if (issues.length > MAX_SUMMARY_ROWS) { + core.summary.addRaw(`…and ${issues.length - MAX_SUMMARY_ROWS} more, see the uploaded reports.`, true); + } + await core.summary.write(); diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index 67708285c3..2f930fb803 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -16,7 +16,6 @@ posthog rageshake securebackup - showkase snackbar spdx swipeable diff --git a/CHANGES.md b/CHANGES.md index edb2c08856..e0833dbe36 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,63 @@ +Changes in Element X v26.09.2 +============================= + + + +## What's Changed +### πŸ™Œ Improvements +* Keep the push foreground service alive while notifications are fetched by @Johennes in https://github.com/element-hq/element-x-android/pull/7639 +* Stop clearing the remote config when clearing the cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7662 +### πŸ› Bugfixes +* Fix fetched remote config being cached with the wrong key by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7626 +* Make sure `enforce_element_pro` is taken into account in FOSS by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7631 +### πŸ—£ Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7628 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7657 +### 🧱 Build +* Allow retrying finding the release assets in release script by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7619 +* Improve detection of invalid placeholders in localazy strings by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7624 +* Add `GRADLE_OPTS` for `sync-localazy.yml` flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7629 +* Clean up ProGuard keep rules files by @bmarty in https://github.com/element-hq/element-x-android/pull/7647 +* Replace Danger with actions/github-script by @bmarty in https://github.com/element-hq/element-x-android/pull/7649 +* Remove Showkase and its in-app component browser by @bmarty in https://github.com/element-hq/element-x-android/pull/7678 +* Improve Renovate configuration to fix issues with Maven Central by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7677 +### Dependency upgrades +* Update dependency com.posthog:posthog-android to v3.60.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7616 +* Update dependency org.jsoup:jsoup to v1.23.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7620 +* Update dependency io.element.android:element-call-embedded to v0.25.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7622 +* Update dependencyAnalysis to v3.19.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7623 +* Update wysiwyg to v2.42.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7627 +* Update android.gradle.plugin to v9.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7603 +* Update actions/setup-java action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7614 +* Update danger/danger-js action to v14 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7602 +* Update camera to v1.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7632 +* Update coil to v3.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7630 +* Update dependency io.sentry:sentry-android to v8.54.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7636 +* Update dependency org.jetbrains.kotlinx:kotlinx-collections-immutable to v0.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7650 +* Bump SDK bindings to `v26.09.3` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7637 +* Update zizmorcore/zizmor-action action to v0.6.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7658 +* Update coil to v3.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7664 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.09.08 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7668 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.09.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7683 +### πŸ’Ό Element Pro +* Fix remote config getting refreshed with `Origin.Cache` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7666 +### Others +* Move knock and gallery to labs by @bmarty in https://github.com/element-hq/element-x-android/pull/7625 +* Say so when this session has no secrets to verify another one with by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7551 +* Expose the timeline item long press and swipe to reply as TalkBack actions by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7458 +* Bump PR priority for our dependencies in Renovate by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7642 +* Import string from Localazy and remove temporary file. by @bmarty in https://github.com/element-hq/element-x-android/pull/7645 +* Revert "Say so when this session has no secrets to verify another one with" by @bmarty in https://github.com/element-hq/element-x-android/pull/7644 +* Maestro (local): queue pending runs in the maestro-test concurrency group instead of cancelling them by @andreBurnt in https://github.com/element-hq/element-x-android/pull/7656 +* Maestro (local): skip the lane on pull requests from forks by @andreBurnt in https://github.com/element-hq/element-x-android/pull/7655 +* Update maplibre compose to 0.15 by @ganfra in https://github.com/element-hq/element-x-android/pull/7663 +* Try improving renovate issues with Maven Central rate limiting by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7667 + +## New Contributors +* @andreBurnt made their first contribution in https://github.com/element-hq/element-x-android/pull/7656 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.1...v26.09.2 + Changes in Element X v26.09.1 ============================= diff --git a/Gemfile b/Gemfile deleted file mode 100644 index a432dd0319..0000000000 --- a/Gemfile +++ /dev/null @@ -1,3 +0,0 @@ -source "https://rubygems.org" - -gem 'danger' diff --git a/build.gradle.kts b/build.gradle.kts index 6bbc568c66..876383e325 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -72,7 +72,7 @@ allprojects { verbose = true reporters { reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) - // To have XML report for Danger + // To have XML report for the CI to annotate the PR, see .github/workflows/quality.yml reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) } val generatedPath = "${layout.buildDirectory.asFile.get()}/generated/" diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 01ad915126..6a84149a47 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -358,7 +358,8 @@ Some dependency, mainly because they are not shared can be declared in `build.gr We have 3 tests frameworks in place, and this should be sufficient to guarantee a good code coverage and limit regressions hopefully: - Maestro to test the global usage of the application. See the related [documentation](../.maestro/README.md). -- Combination of [Showkase](https://github.com/airbnb/Showkase) and [Paparazzi](https://github.com/cashapp/paparazzi), to test UI pixel perfect. To add test, +- Combination of [Paparazzi](https://github.com/cashapp/paparazzi) and + [ComposablePreviewScanner](https://github.com/sergio-sastre/ComposablePreviewScanner), to test UI pixel perfect. To add test, just add `@Preview` for the composable you are adding. See the related [documentation](screenshot_testing.md) and see in the template the file [TemplateView.kt](../features/template/src/main/kotlin/io/element/android/features/template/TemplateView.kt). We create PreviewParam classes to provide different states. See for instance the @@ -449,9 +450,8 @@ number at the bottom 7 times. After tapping, a new "Developer options" entry wil at the bottom of the list. The developer options include feature flags, notification/push history, Element call -customization, Rust SDK log levels, per-feature tracing toggles, Showkase to debug UI -components, rageshake controls, app crash controls, cache details/controls, persistent -storage maintenance tasks. +customization, Rust SDK log levels, per-feature tracing toggles, rageshake controls, app +crash controls, cache details/controls, persistent storage maintenance tasks. Keywords: Developer settings, developer mode diff --git a/docs/continuous_integration.md b/docs/continuous_integration.md index 85a869cbe9..59ee8aac35 100644 --- a/docs/continuous_integration.md +++ b/docs/continuous_integration.md @@ -44,7 +44,7 @@ The CI checks that: 2. The tests are passing 3. The code quality is good (detekt, ktlint, lint) 4. The code is running and smoke tests are passing (maestro) -5. The PullRequest itself is good (with danger) +5. The PullRequest itself is good (see the [PR Checks workflow](../.github/workflows/pr-checks.yml)) 6. Files that must be added with git-lfs are added with git-lfs ## What is the CI reporting diff --git a/docs/danger.md b/docs/danger.md deleted file mode 100644 index 9adb382a3a..0000000000 --- a/docs/danger.md +++ /dev/null @@ -1,105 +0,0 @@ -## Danger - - - -* [What does danger checks](#what-does-danger-checks) - * [PR check](#pr-check) - * [Quality check](#quality-check) -* [Setup](#setup) -* [Run danger locally](#run-danger-locally) -* [Danger user](#danger-user) -* [Useful links](#useful-links) - - - -## What does danger checks - -### PR check - -See the [dangerfile](../tools/danger/dangerfile.js). If you add rules in the dangerfile, please update the list below! - -Here are the checks that Danger does so far: - -- PR description is not empty -- Big PR got a warning to recommend to split -- PR contains a correct title and a label to categorize the release note -- PR does not modify frozen classes -- PR with change on layout should include screenshot in the description (TODO Not supported yet!) -- PR which adds png file warn about the usage of vector drawables -- non draft PR should have a reviewer -- files containing translations are not modified by developers - -### Quality check - -After all the checks that generate checkstyle XML report, such as Ktlint, lint, or Detekt, Danger is run with this [dangerfile](../tools/danger/dangerfile-lint.js), in order to post comments to the PR with the detected error and warnings. - -To run locally, you will have to install the plugin `danger-plugin-lint-report` using: - -```shell -yarn add danger-plugin-lint-report --dev -``` - -## Setup - -This operation should not be necessary, since Danger is already setup for the project. - -To setup danger to the project, run: - -```shell -bundle exec danger init -``` - -## Run danger locally - -When modifying the [dangerfile](../tools/danger/dangerfile.js), you can check it by running Danger locally. - -To run danger locally, install it and run: - -```shell -bundle exec danger pr --dangerfile=./tools/danger/dangerfile.js -``` - -For instance: - -```shell -bundle exec danger pr https://github.com/element-hq/element-android/pull/6637 --dangerfile=./tools/danger/dangerfile.js -``` - -We may need to create a GitHub token to have less API rate limiting, and then set the env var: - -```shell -export DANGER_GITHUB_API_TOKEN='YOUR_TOKEN' -``` - -Swift and Kotlin (just in case) - -```shell -bundle exec danger-swift pr --dangerfile=./tools/danger/dangerfile.js -bundle exec danger-kotlin pr --dangerfile=./tools/danger/dangerfile.js -``` - -## Danger user - -To let Danger check all the PRs, including PRs form forks, a GitHub account have been created: -- login: ElementBot -- password: Stored on Passbolt -- GitHub token: A token with limited access has been created and added to the repository https://github.com/element-hq/element-x-android as secret DANGER_GITHUB_API_TOKEN. This token is not saved anywhere else. In case of problem, just delete it and create a new one, then update the secret. - -PRs from forks do not always have access to the secret `secrets.DANGER_GITHUB_API_TOKEN`, so `secrets.GITHUB_TOKEN` is also provided to the job environment. If `secrets.DANGER_GITHUB_API_TOKEN` is available, it will be used, so user `ElementBot` will comment the PR. Else `secrets.GITHUB_TOKEN` will be used, and bot `github-actions` will comment the PR. - -## Useful links - -- https://danger.systems/ -- https://danger.systems/js/ -- https://danger.systems/js/guides/getting_started.html -- https://danger.systems/js/reference.html -- https://github.com/danger/awesome-danger - -Some danger files to get inspired from - -- https://github.com/artsy/emission/blob/master/dangerfile.ts -- https://github.com/facebook/react-native/blob/master/bots/dangerfile.js -- https://github.com/apollographql/apollo-client/blob/master/config/dangerfile.ts -- https://github.com/styleguidist/react-styleguidist/blob/master/dangerfile.js -- https://github.com/storybooks/storybook/blob/master/dangerfile.js -- https://github.com/ReactiveX/rxjs/blob/master/dangerfile.js diff --git a/enterprise b/enterprise index bb07b4f839..f463003f68 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit bb07b4f839163034fab9c3d1b4ae364f0839b450 +Subproject commit f463003f6813ad33fc00f80786bc83b6a7f0d8e2 diff --git a/fastlane/metadata/android/en-US/changelogs/202609020.txt b/fastlane/metadata/android/en-US/changelogs/202609020.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202609020.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt index bfb9fb538a..883cab6840 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/ClientBuilderEnterpriseHook.kt @@ -13,6 +13,23 @@ import io.element.android.libraries.matrix.api.core.SessionId /** * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features. */ -fun interface ClientBuilderEnterpriseHook { - suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder +interface ClientBuilderEnterpriseHook { + /** + * Customize the [MatrixClientBuilder] for enterprise features. + * This method is invoked everytime a new [MatrixClientBuilder] is created. + * + * @param clientBuilder The [MatrixClientBuilder] to customize. + * @return The customized [MatrixClientBuilder]. + */ + suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder + + /** + * Customize the [MatrixClientBuilder] for enterprise features. + * This method is invoked as well as the other method when a new [MatrixClientBuilder] is created to build a client for a specific session. + * + * @param clientBuilder The [MatrixClientBuilder] to customize. + * @param sessionId The [SessionId] for which the [MatrixClientBuilder] is being created. + * @return The customized [MatrixClientBuilder]. + */ + suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt index ee22e4399d..44887a0736 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultClientBuilderEnterpriseHook.kt @@ -18,7 +18,12 @@ import io.element.android.libraries.matrix.api.core.SessionId */ @ContributesBinding(AppScope::class) class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { - override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { + // No modification + return clientBuilder + } + + override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { // No modification return clientBuilder } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeClientBuilderEnterpriseHook.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeClientBuilderEnterpriseHook.kt new file mode 100644 index 0000000000..3781f25382 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeClientBuilderEnterpriseHook.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.enterprise.test + +import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook +import io.element.android.libraries.matrix.api.MatrixClientBuilder +import io.element.android.libraries.matrix.api.core.SessionId + +class FakeClientBuilderEnterpriseHook( + private val beforeClientCreationResult: (MatrixClientBuilder) -> MatrixClientBuilder = { it }, + private val beforeClientCreationWithSessionResult: (MatrixClientBuilder, SessionId) -> MatrixClientBuilder = { clientBuilder, _ -> clientBuilder }, +) : ClientBuilderEnterpriseHook { + override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { + return beforeClientCreationResult(clientBuilder) + } + + override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + return beforeClientCreationWithSessionResult(clientBuilder, sessionId) + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 6e153344c9..fe7c4aba3e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult @@ -94,7 +95,14 @@ class GlobalSearchPresenter( AsyncData.Uninitialized } - launch { roomListSearchDataSource.setSearchQuery(queryState.text.toString()) } + // Apply the query to the room list search, looking only for joined rooms matching the query + launch { + roomListSearchDataSource.setSearchQuery( + searchQuery = queryState.text.toString(), + additionalFilters = RoomListFilter.Joined, + ) + } + // Apply the query to the message search too launch { currentMessageSearch.setQuery(queryState.text.toString()) .onFailure { Timber.e(it, "Could not set query for message search") } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt index df7ed0dd37..cf39ea244e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchDataSource.kt @@ -60,11 +60,15 @@ class RoomListSearchDataSource( roomList.updateVisibleRange(visibleRange) } - suspend fun setSearchQuery(searchQuery: String) = coroutineScope { + suspend fun setSearchQuery(searchQuery: String, additionalFilters: RoomListFilter?) = coroutineScope { val filter = if (searchQuery.isBlank()) { RoomListFilter.None } else { - RoomListFilter.NormalizedMatchRoomName(searchQuery) + if (additionalFilters != null) { + RoomListFilter.all(additionalFilters, RoomListFilter.NormalizedMatchRoomName(searchQuery)) + } else { + RoomListFilter.NormalizedMatchRoomName(searchQuery) + } } roomList.updateFilter(filter) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt index 763fbb23d0..d04eb318c3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt @@ -39,7 +39,7 @@ class RoomListSearchPresenter( val dataSource = remember { dataSourceFactory.create(coroutineScope) } LaunchedEffect(searchQuery.text) { - dataSource.setSearchQuery(searchQuery.text.toString()) + dataSource.setSearchQuery(searchQuery = searchQuery.text.toString(), additionalFilters = null) } fun handleEvent(event: RoomListSearchEvent) { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 94c19bb81d..0c6b063705 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -23,6 +23,9 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Joined +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -142,6 +145,9 @@ class GlobalSearchPresenterTest { testScheduler.advanceUntilIdle() roomList.summaries.emit(listOf(aRoomSummary())) + // The filter is updated to include the search query and only search joined rooms + assertThat(roomList.currentFilter.value).isEqualTo(RoomListFilter.all(Joined, NormalizedMatchRoomName("A query"))) + // Wait for the presenter to emit a loading state before the success state consumeItemsUntilPredicate { it.results is AsyncData.Loading } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 97d3465ce7..e0213f2fb5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -51,6 +51,7 @@ import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.ApplicationContext @@ -103,6 +104,7 @@ class MessagesNode( private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val eventContentValidationCache: EventContentValidationCache, private val emojiPickerRenderer: EmojiPickerRenderer, + private val dispatchers: CoroutineDispatchers, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val focusedEventId: EventId?, @@ -111,7 +113,12 @@ class MessagesNode( private val inputs = inputs() private val callback: Callback = callback() - private val timelineController = TimelineController(room, room.liveTimeline) + private val timelineController = TimelineController( + room = room, + liveTimeline = room.liveTimeline, + roomCoroutineScope = room.roomCoroutineScope, + dispatchers = dispatchers, + ) private val presenter = presenterFactory.create( navigator = this, composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index cf3c701015..b5542ec4be 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -54,6 +54,7 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer @@ -95,6 +96,7 @@ class ThreadedMessagesNode( private val appNavigationStateService: AppNavigationStateService, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val emojiPickerRenderer: EmojiPickerRenderer, + private val dispatchers: CoroutineDispatchers, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val threadRootEventId: ThreadId, @@ -113,7 +115,12 @@ class ThreadedMessagesNode( */ private suspend fun createPresenter(): Presenter { val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() - val timelineController = TimelineController(room, threadedTimeline) + val timelineController = TimelineController( + room, + liveTimeline = threadedTimeline, + roomCoroutineScope = room.roomCoroutineScope, + dispatchers = dispatchers, + ) this.timelineController = timelineController return presenterFactory.create( navigator = this, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index e41ac7bee7..05f7a06054 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -12,7 +12,10 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.binding import io.element.android.features.messages.impl.timeline.di.LiveTimeline +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.RoomCoroutineScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.room.CreateTimelineParams @@ -23,7 +26,6 @@ import io.element.android.libraries.matrix.api.timeline.TimelineProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -47,8 +49,10 @@ import java.util.Optional class TimelineController( private val room: JoinedRoom, @LiveTimeline private val liveTimeline: Timeline, + @RoomCoroutineScope private val roomCoroutineScope: CoroutineScope, + dispatchers: CoroutineDispatchers, ) : Closeable, TimelineProvider { - private val coroutineScope = CoroutineScope(SupervisorJob()) + private val coroutineScope = roomCoroutineScope.childScope(dispatchers.computation, "TimelineController") private val liveTimelineFlow = flowOf(liveTimeline) private val detachedTimelineFlow = MutableStateFlow>(Optional.empty()) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 4c0abc0a34..949443f1f7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -1484,7 +1484,12 @@ class MessagesPresenterTest { clipboardHelper = clipboardHelper, htmlConverterProvider = FakeHtmlConverterProvider(), buildMeta = aBuildMeta(), - timelineController = TimelineController(joinedRoom, timeline), + timelineController = TimelineController( + room = joinedRoom, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = coroutineDispatchers, + ), permalinkParser = permalinkParser, analyticsService = analyticsService, encryptionService = encryptionService, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index faf0bbe6ed..a8f6c338f5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -65,6 +65,7 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -307,7 +308,12 @@ class MessageComposerPresenterSlashCommandTest { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, - timelineController = TimelineController(room, timeline), + timelineController = TimelineController( + room = room, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ), draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 4170ae41ab..6744cdebbb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -113,6 +113,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.waitForPredicate import io.mockk.mockk import kotlinx.collections.immutable.ImmutableList @@ -1707,7 +1708,12 @@ class MessageComposerPresenterTest : RobolectricTest() { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permalinkParser = permalinkParser, permalinkBuilder = permalinkBuilder, - timelineController = TimelineController(room, timeline), + timelineController = TimelineController( + room = room, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ), draftService = draftService, mentionSpanProvider = mentionSpanProvider, pillificationHelper = textPillificationHelper, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index bc36766bac..d00062ba6b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -35,7 +37,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -74,7 +79,10 @@ class TimelineControllerTest { } } ) - val sut = TimelineController(joinedRoom, liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -102,7 +110,10 @@ class TimelineControllerTest { val joinedRoom = FakeJoinedRoom( liveTimeline = liveTimeline ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) @@ -121,7 +132,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> assertThat(state).isEqualTo(liveTimeline) @@ -149,7 +163,10 @@ class TimelineControllerTest { val joinedRoom = FakeJoinedRoom( liveTimeline = liveTimeline ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) assertThat(sut.timelineItems().first()).hasSize(1) } @@ -171,7 +188,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { sut.focusOnEvent(AN_EVENT_ID, null) awaitItem().also { state -> @@ -196,7 +216,10 @@ class TimelineControllerTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) } ) - val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) + val sut = createTimelineController( + room = joinedRoom, + liveTimeline = liveTimeline, + ) sut.activeTimelineFlow().test { awaitItem().also { state -> @@ -220,12 +243,14 @@ class TimelineControllerTest { } } -internal fun createTimelineController( +internal fun TestScope.createTimelineController( room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()), liveTimeline: Timeline = FakeTimeline(name = "live"), ): TimelineController { return TimelineController( room = room, - liveTimeline = liveTimeline + liveTimeline = liveTimeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 290246ffb9..60f13f4ea8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -1079,7 +1079,11 @@ class TimelinePresenterTest { val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last() assertThat(focusedState.isLive).isFalse() initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) - advanceUntilIdle() + + // Give some time for any potential pagination to be called, but it should not be called because the focused event is not rendered yet. + // Also, given enough time, a new state should be emitted with isLive = false, because we switched to a focused event timeline. + runCurrent() + assert(paginateLambda).isNeverCalled() assertThat(expectMostRecentItem().isLive).isFalse() } @@ -1906,7 +1910,12 @@ class TimelinePresenterTest { sendPollResponseAction = sendPollResponseAction, sessionPreferencesStore = sessionPreferencesStore, timelineItemIndexer = timelineItemIndexer, - timelineController = TimelineController(room, timeline), + timelineController = TimelineController( + room = room, + liveTimeline = timeline, + roomCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + ), resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 12554eb6fa..11f863e26d 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -94,7 +94,6 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.serialization.json) api(projects.features.preferences.api) - implementation(libs.showkase) implementation(platform(libs.network.okhttp.bom)) implementation(libs.network.okhttp) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt index 7a8c3222d1..e0b06bbc53 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -8,10 +8,8 @@ package io.element.android.features.preferences.impl.developer -import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.airbnb.android.showkase.models.Showkase import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -20,7 +18,6 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.preferences.api.ExtraDeveloperOptionsRenderer import io.element.android.libraries.architecture.callback -import io.element.android.libraries.designsystem.showkase.getBrowserIntent import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -47,17 +44,10 @@ class DeveloperSettingsNode( @Composable override fun View(modifier: Modifier) { - val activity = requireNotNull(LocalActivity.current) - fun openShowkase() { - val intent = Showkase.getBrowserIntent(activity) - activity.startActivity(intent) - } - val state = presenter.present() DeveloperSettingsView( state = state, modifier = modifier, - onOpenShowkase = ::openShowkase, onPushHistoryClick = callback::navigateToPushHistory, onBackClick = callback::onDone, extraOptions = { extraDeveloperOptionsRenderer.Render(Modifier) }, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 8eff49c471..992a7e6212 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -44,7 +44,6 @@ import io.mhssn.colorpicker.ColorPickerType @Composable fun DeveloperSettingsView( state: DeveloperSettingsState, - onOpenShowkase: () -> Unit, onPushHistoryClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, @@ -84,7 +83,6 @@ fun DeveloperSettingsView( // Note: this is OK to hardcode strings in this debug screen. AppDeveloperSettingsView( state = state.appDeveloperSettingsState, - onOpenShowkase = onOpenShowkase, ) SessionCategory(deviceId = state.deviceId) NotificationCategory( @@ -255,7 +253,6 @@ internal fun DeveloperSettingsViewPreview( ) = ElementPreview { DeveloperSettingsView( state = state, - onOpenShowkase = {}, onPushHistoryClick = {}, onBackClick = {}, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt index 89ef240a95..f73a23063d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsNode.kt @@ -7,10 +7,8 @@ package io.element.android.features.preferences.impl.developer.appsettings -import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.airbnb.android.showkase.models.Showkase import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -21,7 +19,6 @@ import io.element.android.annotations.ContributesNode import io.element.android.features.preferences.api.ExtraDeveloperOptionsRenderer import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.architecture.callback -import io.element.android.libraries.designsystem.showkase.getBrowserIntent @ContributesNode(AppScope::class) @AssistedInject @@ -35,17 +32,10 @@ class AppDeveloperSettingsNode( @Composable override fun View(modifier: Modifier) { - val activity = requireNotNull(LocalActivity.current) - fun openShowkase() { - val intent = Showkase.getBrowserIntent(activity) - activity.startActivity(intent) - } - val state = presenter.present() AppDeveloperSettingsPage( state = state, modifier = modifier, - onOpenShowkase = ::openShowkase, onBackClick = callback::onDone, extraOptions = { extraDeveloperOptionsRenderer.Render(Modifier) }, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt index d7d62aa325..34560f9d23 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPage.kt @@ -21,7 +21,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable fun AppDeveloperSettingsPage( state: AppDeveloperSettingsState, - onOpenShowkase: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, extraOptions: @Composable () -> Unit = {}, @@ -38,7 +37,6 @@ fun AppDeveloperSettingsPage( ) { AppDeveloperSettingsView( state = state, - onOpenShowkase = onOpenShowkase, modifier = Modifier.padding(top = 8.dp) ) extraOptions() @@ -50,7 +48,6 @@ fun AppDeveloperSettingsPage( internal fun AppDeveloperSettingsPagePreview() = ElementPreview { AppDeveloperSettingsPage( state = anAppDeveloperSettingsState(), - onOpenShowkase = {}, onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt index 7b1401b5e5..1285269ca9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -42,7 +42,6 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun AppDeveloperSettingsView( state: AppDeveloperSettingsState, - onOpenShowkase: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -82,14 +81,6 @@ fun AppDeveloperSettingsView( ) } } - PreferenceCategory(title = "Showkase") { - ListItem( - content = { - Text("Open Showkase browser") - }, - onClick = onOpenShowkase - ) - } RageshakePreferencesView( state = state.rageshakeState, ) @@ -186,6 +177,5 @@ internal fun AppDeveloperSettingsViewPreview( ) = ElementPreview { AppDeveloperSettingsView( state = state, - onOpenShowkase = {}, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index fcc69cda91..8b50f0e684 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -70,21 +70,6 @@ class DeveloperSettingsViewTest : RobolectricTest() { eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules) } - @Config(qualifiers = "h2000dp") - @Test - fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { - setDeveloperSettingsView( - state = aDeveloperSettingsState( - eventSink = eventsRecorder - ), - onOpenShowkase = it - ) - onNodeWithText("Open Showkase browser").performClick() - } - } - @Config(qualifiers = "h2400dp") @Test fun `clicking on clear cache emits the expected event`() = runAndroidComposeUiTest { @@ -101,14 +86,12 @@ class DeveloperSettingsViewTest : RobolectricTest() { private fun AndroidComposeUiTest.setDeveloperSettingsView( state: DeveloperSettingsState, - onOpenShowkase: () -> Unit = EnsureNeverCalled(), onPushHistoryClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled() ) { setContent { DeveloperSettingsView( state = state, - onOpenShowkase = onOpenShowkase, onPushHistoryClick = onPushHistoryClick, onBackClick = onBackClick, ) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt index 3500b96731..891cf44b96 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPageTest.kt @@ -64,21 +64,6 @@ class AppDeveloperSettingsPageTest : RobolectricTest() { eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev")) } - @Config(qualifiers = "h2000dp") - @Test - fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { - setAppDeveloperSettingsView( - state = anAppDeveloperSettingsState( - eventSink = eventsRecorder - ), - onOpenShowkase = it - ) - onNodeWithText("Open Showkase browser").performClick() - } - } - @Config(qualifiers = "h1024dp") @Test fun `clicking on log level emits the expected event`() = runAndroidComposeUiTest { @@ -96,13 +81,11 @@ class AppDeveloperSettingsPageTest : RobolectricTest() { private fun AndroidComposeUiTest.setAppDeveloperSettingsView( state: AppDeveloperSettingsState, - onOpenShowkase: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { AppDeveloperSettingsPage( state = state, - onOpenShowkase = onOpenShowkase, onBackClick = onBackClick, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 82c2495baf..eabc93af11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,9 +39,7 @@ datetime = "0.8.0" serialization_json = "1.11.0" #other -coil = "3.6.1" -# Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420 -showkase = "1.0.5" +coil = "3.6.2" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" @@ -186,7 +184,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.08" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.9" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -198,8 +196,6 @@ coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.2" -showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } -showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } jsoup = "org.jsoup:jsoup:1.23.2" appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0" @@ -230,7 +226,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.60.7" -sentry = "io.sentry:sentry-android:8.54.0" +sentry = "io.sentry:sentry-android:8.55.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" diff --git a/libraries/compound/build.gradle.kts b/libraries/compound/build.gradle.kts index ce8479c47a..04d55081ee 100644 --- a/libraries/compound/build.gradle.kts +++ b/libraries/compound/build.gradle.kts @@ -22,7 +22,6 @@ android { } dependencies { - implementation(libs.showkase) testCommonDependencies(libs) testImplementation(libs.test.roborazzi) testImplementation(libs.test.roborazzi.compose) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt deleted file mode 100644 index 4f36d0cc99..0000000000 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.compound.showkase - -import com.airbnb.android.showkase.annotation.ShowkaseRoot -import com.airbnb.android.showkase.annotation.ShowkaseRootModule - -@ShowkaseRoot -class CompoundShowkaseRootModule : ShowkaseRootModule diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt index 9417a306bf..000cd6a9d4 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/CompoundTypography.kt @@ -16,19 +16,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp -import com.airbnb.android.showkase.annotation.ShowkaseTypography import io.element.android.compound.tokens.generated.TypographyTokens // 32px (Material) vs 34px, it's the closest one -@ShowkaseTypography(name = "M3 Headline Large", group = "Compound") internal val compoundHeadingXlRegular = TypographyTokens.fontHeadingXlRegular // both are 28px -@ShowkaseTypography(name = "M3 Headline Medium", group = "Compound") internal val compoundHeadingLgRegular = TypographyTokens.fontHeadingLgRegular // These are the default M3 values, but we're setting them manually so an update in M3 doesn't break our designs -@ShowkaseTypography(name = "M3 Headline Small", group = "Compound") internal val defaultHeadlineSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, @@ -40,39 +36,27 @@ internal val defaultHeadlineSmall = TextStyle( ) // 22px (Material) vs 20px, it's the closest one -@ShowkaseTypography(name = "M3 Title Large", group = "Compound") internal val compoundHeadingMdRegular = TypographyTokens.fontHeadingMdRegular // 16px both -@ShowkaseTypography(name = "M3 Title Medium", group = "Compound") internal val compoundBodyLgMedium = TypographyTokens.fontBodyLgMedium // 14px both -@ShowkaseTypography(name = "M3 Title Small", group = "Compound") internal val compoundBodyMdMedium = TypographyTokens.fontBodyMdMedium // 16px both -@ShowkaseTypography(name = "M3 Body Large", group = "Compound") internal val compoundBodyLgRegular = TypographyTokens.fontBodyLgRegular // 14px both -@ShowkaseTypography(name = "M3 Body Medium", group = "Compound") internal val compoundBodyMdRegular = TypographyTokens.fontBodyMdRegular // 12px both -@ShowkaseTypography(name = "M3 Body Small", group = "Compound") internal val compoundBodySmRegular = TypographyTokens.fontBodySmRegular -// 14px both, Title Small uses the same token so we have to declare it twice -@ShowkaseTypography(name = "M3 Label Large", group = "Compound") -internal val compoundBodyMdMedium_LabelLarge = TypographyTokens.fontBodyMdMedium - // 12px both -@ShowkaseTypography(name = "M3 Label Medium", group = "Compound") internal val compoundBodySmMedium = TypographyTokens.fontBodySmMedium // 11px both -@ShowkaseTypography(name = "M3 Label Small", group = "Compound") internal val compoundBodyXsMedium = TypographyTokens.fontBodyXsMedium internal val compoundTypography = Typography( @@ -88,7 +72,7 @@ internal val compoundTypography = Typography( bodyLarge = compoundBodyLgRegular, bodyMedium = compoundBodyMdRegular, bodySmall = compoundBodySmRegular, - labelLarge = compoundBodyMdMedium_LabelLarge, + labelLarge = compoundBodyMdMedium, labelMedium = compoundBodySmMedium, labelSmall = compoundBodyXsMedium, ) diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 9ef66f7b2f..7020b5dba6 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -10,7 +10,6 @@ import extension.testCommonDependencies plugins { id("io.element.android-compose-library") - alias(libs.plugins.ksp) id("kotlin-parcelize") } @@ -20,12 +19,6 @@ android { buildFeatures { buildConfig = true } - - buildTypes { - getByName("release") { - consumerProguardFiles("consumer-rules.keep") - } - } } dependencies { @@ -43,8 +36,5 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - ksp(libs.showkase.processor) - implementation(libs.showkase) - testCommonDependencies(libs) } diff --git a/libraries/designsystem/consumer-rules.keep b/libraries/designsystem/consumer-rules.keep deleted file mode 100644 index c84e1c0613..0000000000 --- a/libraries/designsystem/consumer-rules.keep +++ /dev/null @@ -1,5 +0,0 @@ -# Showkase generates this class, and Showkase's ShowkaseBrowserActivity loads it reflectively with -# Class.forName(...).getDeclaredConstructor().newInstance(), so its constructor must be kept too. --keep class io.element.android.libraries.designsystem.showkase.DesignSystemShowkaseRootModuleCodegen { - (); -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt index 5967b660dd..266746d723 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/WithFontScale.kt @@ -14,8 +14,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density /** - * Showkase does not take into account the `fontScale` parameter of the Preview annotation, so alter the - * LocalDensity in the CompositionLocalProvider. + * Render the content at the given font scale, by altering the `LocalDensity` in the + * CompositionLocalProvider. Useful to preview a Composable at several font scales. */ @Composable fun WithFontScale(fontScale: Float, content: @Composable () -> Unit) { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt deleted file mode 100644 index abb420a0a0..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2022-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.designsystem.showkase - -import com.airbnb.android.showkase.annotation.ShowkaseRoot -import com.airbnb.android.showkase.annotation.ShowkaseRootModule - -@ShowkaseRoot -class DesignSystemShowkaseRootModule : ShowkaseRootModule diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt index 44485fed62..f7325fb211 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt @@ -67,6 +67,11 @@ sealed interface RoomListFilter { */ data object Invite : RoomListFilter + /** + * A filter that matches rooms with Joined membership. + */ + data object Joined : RoomListFilter + /** * A filter that matches either Group,People rooms or Space. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 985fd3cdef..9b381460a2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -35,7 +35,6 @@ import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.RequestConfig @@ -100,7 +99,12 @@ class RustMatrixClientFactory( ) .homeserverUrl(sessionData.homeserverUrl) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) - .let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } + .let { + (clientBuilderEnterpriseHook.beforeClientCreationWithSession( + clientBuilder = RustMatrixClientBuilder(it), + sessionId = SessionId(sessionData.userId), + ) as RustMatrixClientBuilder).inner + } .use { it.build() } client.setMediaRetentionPolicy( @@ -118,14 +122,6 @@ class RustMatrixClientFactory( client.restoreSession(sessionData.toSession()) - create(client, sessionData, isMessageSearchAvailable) - } - - suspend fun create( - client: Client, - sessionData: SessionData, - isMessageSearchAvailable: Boolean, - ): RustMatrixClient { val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens() client.setUtdDelegate(UtdTracker(analyticsService)) @@ -135,7 +131,7 @@ class RustMatrixClientFactory( .withOfflineMode() .finish() - return RustMatrixClient( + RustMatrixClient( sessionPaths = sessionData.getSessionPaths(), innerClient = client, sessionStore = sessionStore, @@ -227,6 +223,9 @@ class RustMatrixClientFactory( // Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep proxyProvider.provides()?.let { proxy(it) } ?: this } + .let { + (clientBuilderEnterpriseHook.beforeClientCreation(RustMatrixClientBuilder(it)) as RustMatrixClientBuilder).inner + } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index a175f253ca..681aa6ba64 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.impl.keys.SecretGenerator import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first @@ -101,17 +102,7 @@ class RustMatrixAuthenticationService( runCatchingExceptions { val sessionData = sessionStore.getSession(sessionId.value) if (sessionData != null) { - if (sessionData.isTokenValid) { - // Use the sessionData.passphrase, which can be null for a previously created session - if (sessionData.passphrase == null) { - Timber.w("Restoring a session without a passphrase") - } else { - Timber.w("Restoring a session with a passphrase") - } - rustMatrixClientFactory.create(sessionData) - } else { - throw SessionRestorationException.InvalidToken() - } + restoreSession(sessionData) } else { throw SessionRestorationException.MissingSession(sessionId) } @@ -120,6 +111,19 @@ class RustMatrixAuthenticationService( } } + private suspend fun restoreSession(sessionData: SessionData): MatrixClient { + if (!sessionData.isTokenValid) { + throw SessionRestorationException.InvalidToken() + } + // Use the sessionData.passphrase, which can be null for a previously created session + if (sessionData.passphrase == null) { + Timber.w("Restoring a session without a passphrase") + } else { + Timber.w("Restoring a session with a passphrase") + } + return rustMatrixClientFactory.create(sessionData) + } + private fun getDatabaseKey(): ClientSecret { Timber.d("New sessions will be encrypted with a raw key") return secretGenerator.generateKey() @@ -137,7 +141,7 @@ class RustMatrixAuthenticationService( client.homeserverLoginDetails().map() }.onFailure { - clear(destroyClient = true) + clear() }.mapFailure { failure -> Timber.e(failure, "Failed to set homeserver to $homeserver") failure.mapAuthenticationException() @@ -165,18 +169,7 @@ class RustMatrixAuthenticationService( passphrase = pendingKey.formattedAsString(), sessionPaths = currentSessionPaths, ) - val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) - - // Apply enterprise hooks to the newly created client as soon as possible - clientEnterpriseHook(matrixClient) - - newMatrixClientObservers.forEach { it.invoke(matrixClient) } - sessionStore.addSession(sessionData) - - // Clean up the strong reference held here since it's no longer necessary - clear(destroyClient = false) - - SessionId(sessionData.userId) + finalizeClientCreation(sessionData) }.mapFailure { failure -> Timber.e(failure, "Failed to login") failure.mapAuthenticationException() @@ -305,20 +298,7 @@ class RustMatrixAuthenticationService( passphrase = pendingKey.formattedAsString(), sessionPaths = currentSessionPaths, ) - val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) - - // Apply enterprise hooks to the newly created client as soon as possible - clientEnterpriseHook(matrixClient) - - matrixClient.waitForKnownVerificationState() - - newMatrixClientObservers.forEach { it.invoke(matrixClient) } - sessionStore.addSession(sessionData) - - // Clean up the strong reference held here since it's no longer necessary - clear(destroyClient = false) - - SessionId(sessionData.userId) + finalizeClientCreation(sessionData) }.mapFailure { failure -> Timber.e(failure, "Failed to login with OAuth") failure.mapAuthenticationException() @@ -357,6 +337,7 @@ class RustMatrixAuthenticationService( sessionPaths = emptySessionPaths, qrCodeData = sdkQrCodeLoginData, ) + currentClient = client client.newLoginWithQrCodeHandler( oauthConfiguration = oAuthConfiguration, ).use { @@ -374,18 +355,7 @@ class RustMatrixAuthenticationService( passphrase = pendingKey.formattedAsString(), sessionPaths = emptySessionPaths, ) - val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) - - // Apply enterprise hooks to the newly created client as soon as possible - clientEnterpriseHook(matrixClient) - - newMatrixClientObservers.forEach { it.invoke(matrixClient) } - sessionStore.addSession(sessionData) - - // Clean up the strong reference held here since it's no longer necessary - clear(destroyClient = false) - - SessionId(sessionData.userId) + finalizeClientCreation(sessionData) }.mapFailure { when (it) { is QrCodeDecodeException -> QrErrorMapper.map(it) @@ -393,6 +363,8 @@ class RustMatrixAuthenticationService( else -> it } }.onFailure { throwable -> + // A QR code login always builds its own client, so it can be disposed of on failure. + clear() if (throwable is CancellationException) { throw throwable } @@ -400,6 +372,27 @@ class RustMatrixAuthenticationService( } } + private suspend fun finalizeClientCreation(sessionData: SessionData): SessionId { + // Close the client which was used to perform the login before creating the final client. + // Both use the same session paths, so their SQLite stores must never be opened at the same time. + clear() + + val matrixClient = restoreSession(sessionData) + // Apply enterprise hooks to the newly created client as soon as possible + clientEnterpriseHook(matrixClient) + + matrixClient.waitForKnownVerificationState() + + newMatrixClientObservers.forEach { it.invoke(matrixClient) } + sessionStore.addSession(sessionData) + + // The session paths now hold the data of the account which has just been logged in, so forget + // them: they must not be deleted by the rotateSessionPath() of the next login attempt. + sessionPaths = null + + return SessionId(sessionData.userId) + } + private suspend fun makeClient( sessionPaths: SessionPaths, config: suspend ClientBuilder.() -> ClientBuilder, @@ -449,10 +442,8 @@ class RustMatrixAuthenticationService( .build() } - private fun clear(destroyClient: Boolean) { - if (destroyClient) { - currentClient?.close() - } + private fun clear() { + currentClient?.close() currentClient = null } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt index 0e0d979a1e..c93329f7d5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterMapper.kt @@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Deduplicat import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Favourite import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Identifiers import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Joined import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonLeft import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None @@ -73,6 +74,7 @@ internal object RoomListFilterMapper { is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName( pattern = filter.pattern ) + RoomListFilter.Joined -> Joined RoomListFilter.Invite -> Invite } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 6d3afad5c3..b559b7d4d0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -9,11 +9,13 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider +import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -51,6 +53,7 @@ fun TestScope.createRustMatrixClientFactory( ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -63,7 +66,7 @@ fun TestScope.createRustMatrixClientFactory( featureFlagService = FakeFeatureFlagService(), timelineEventFilterFactory = FakeTimelineEventFilterFactory(), clientBuilderProvider = clientBuilderProvider, - sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), + sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, workManagerScheduler = workManagerScheduler, - clientBuilderEnterpriseHook = { builder, _ -> builder }, + clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index d0b87ef53a..f18f633889 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory @@ -77,6 +78,6 @@ class RustTemporaryMatrixClientFactoryTest { clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - clientBuilderEnterpriseHook = { builder, _ -> builder }, + clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index 5d66e4f4bd..d81c6021da 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -12,25 +12,44 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.impl.ClientBuilderProvider import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider +import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiLoginWithQrCodeHandler +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilder +import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider +import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilder +import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider +import io.element.android.libraries.matrix.test.A_HOMESERVER_URL +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.HumanQrLoginException import java.io.File class RustMatrixAuthenticationServiceTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + @Test fun `setHomeserver is successful`() = runTest { val sut = createRustMatrixAuthenticationService( @@ -74,20 +93,183 @@ class RustMatrixAuthenticationServiceTest { closeResult.assertions().isCalledOnce() } + @Test + fun `login closes the client used to log in before building the client of the session`() = runTest { + val events = mutableListOf() + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeSequentialClientBuilderProvider( + { + events.add("build login client") + FakeFfiClient( + homeserverLoginDetailsResult = { FakeFfiHomeserverLoginDetails() }, + loginResult = { _, _ -> }, + closeResult = { events.add("close login client") }, + ) + }, + { + events.add("build session client") + FakeFfiClient(withUtdHook = {}) + }, + ), + ) + + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() + assertThat(sut.login("alice", "password").getOrNull()).isEqualTo(A_USER_ID) + + // The two clients share the same session paths, so the login one must be closed first. + assertThat(events).containsExactly("build login client", "close login client", "build session client").inOrder() + } + + @Test + fun `loginWithQrCode closes the client used to log in before building the client of the session`() = runTest { + val events = mutableListOf() + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeSequentialClientBuilderProvider( + { + events.add("build login client") + FakeFfiClient( + newLoginWithQrCodeHandlerResult = { FakeFfiLoginWithQrCodeHandler() }, + closeResult = { events.add("close login client") }, + ) + }, + { + events.add("build session client") + FakeFfiClient(withUtdHook = {}) + }, + ), + ) + + val result = sut.loginWithQrCode(aSdkQrCodeLoginData()) {} + + assertThat(result.getOrNull()).isEqualTo(A_USER_ID) + assertThat(events).containsExactly("build login client", "close login client", "build session client").inOrder() + } + + @Test + fun `loginWithQrCode closes the client it created when the login fails`() = runTest { + val closeResult = lambdaRecorder {} + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + newLoginWithQrCodeHandlerResult = { + FakeFfiLoginWithQrCodeHandler( + scanResult = { throw HumanQrLoginException.Unknown() }, + ) + }, + closeResult = closeResult, + ) + }, + ) + }, + ), + ) + + assertThat(sut.loginWithQrCode(aSdkQrCodeLoginData()) {}.isFailure).isTrue() + closeResult.assertions().isCalledOnce() + } + + private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData( + FakeFfiQrCodeData( + baseUrlResult = { A_HOMESERVER_URL }, + ) + ) + + /** + * A [ClientBuilderProvider] handing out one [Client] per call, in order. + */ + private class FakeSequentialClientBuilderProvider( + private vararg val clients: () -> Client, + ) : ClientBuilderProvider { + private var index = 0 + override fun provide(): ClientBuilder = FakeFfiClientBuilder(buildResult = clients[index++]) + } + + @Test + fun `a new login does not delete the session data of the account which has just been logged in`() = runTest { + val storeBuilderProvider = SessionDirectoryCreatingSqliteStoreBuilderProvider() + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeSequentialClientBuilderProvider( + { aLoginFakeFfiClient() }, + { FakeFfiClient(withUtdHook = {}) }, + { aLoginFakeFfiClient() }, + ), + sessionPathsFactory = SessionPathsFactory(temporaryFolder.newFolder("base"), temporaryFolder.newFolder("cache")), + sqliteStoreBuilderProvider = storeBuilderProvider, + ) + + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() + assertThat(sut.login("alice", "password").getOrNull()).isEqualTo(A_USER_ID) + val loggedInSessionPaths = storeBuilderProvider.providedSessionPaths.first() + + // Adding another account rotates the session paths, which must not touch the previous account. + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() + + assertThat(loggedInSessionPaths.fileDirectory.exists()).isTrue() + assertThat(loggedInSessionPaths.cacheDirectory.exists()).isTrue() + } + + @Test + fun `a new login deletes the session data of a previous failed login attempt`() = runTest { + val storeBuilderProvider = SessionDirectoryCreatingSqliteStoreBuilderProvider() + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeSequentialClientBuilderProvider( + { aLoginFakeFfiClient() }, + { throw IllegalStateException("Failed to build the client of the session") }, + { aLoginFakeFfiClient() }, + ), + sessionPathsFactory = SessionPathsFactory(temporaryFolder.newFolder("base"), temporaryFolder.newFolder("cache")), + sqliteStoreBuilderProvider = storeBuilderProvider, + ) + + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() + assertThat(sut.login("alice", "password").isFailure).isTrue() + val abandonedSessionPaths = storeBuilderProvider.providedSessionPaths.first() + + assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() + + assertThat(abandonedSessionPaths.fileDirectory.exists()).isFalse() + assertThat(abandonedSessionPaths.cacheDirectory.exists()).isFalse() + } + + private fun aLoginFakeFfiClient() = FakeFfiClient( + homeserverLoginDetailsResult = { FakeFfiHomeserverLoginDetails() }, + loginResult = { _, _ -> }, + ) + + /** + * A [SqliteStoreBuilderProvider] which creates the session directories, like the SDK does when it + * opens its stores, and records them so that a test can assert on their lifecycle. + */ + private class SessionDirectoryCreatingSqliteStoreBuilderProvider : SqliteStoreBuilderProvider { + val providedSessionPaths = mutableListOf() + + override fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder { + sessionPaths.fileDirectory.mkdirs() + sessionPaths.cacheDirectory.mkdirs() + providedSessionPaths.add(sessionPaths) + return FakeSqliteStoreBuilder() + } + } + private fun TestScope.createRustMatrixAuthenticationService( - sessionStore: SessionStore = InMemorySessionStore(), + sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), enterpriseService: EnterpriseService = FakeEnterpriseService(), + sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")), + sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), ): RustMatrixAuthenticationService { - val baseDirectory = File("/base") - val cacheDirectory = File("/cache") val rustMatrixClientFactory = createRustMatrixClientFactory( - cacheDirectory = cacheDirectory, + cacheDirectory = File("/cache"), sessionStore = sessionStore, clientBuilderProvider = clientBuilderProvider, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, ) return RustMatrixAuthenticationService( - sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory), + sessionPathsFactory = sessionPathsFactory, coroutineDispatchers = testCoroutineDispatchers(), sessionStore = sessionStore, rustMatrixClientFactory = rustMatrixClientFactory, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 86587da6c1..8c6411197a 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -25,10 +25,12 @@ import org.matrix.rustcomponents.sdk.Encryption import org.matrix.rustcomponents.sdk.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.IgnoredUsersListener +import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationSettings +import org.matrix.rustcomponents.sdk.OAuthConfiguration import org.matrix.rustcomponents.sdk.ProfileListener import org.matrix.rustcomponents.sdk.PusherIdentifiers import org.matrix.rustcomponents.sdk.PusherKind @@ -57,6 +59,8 @@ class FakeFfiClient( private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, private val getProfileResult: (String) -> UserProfile = { aRustUserProfile() }, private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, + private val loginResult: (String, String) -> Unit = { _, _ -> lambdaError() }, + private val newLoginWithQrCodeHandlerResult: () -> LoginWithQrCodeHandler = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), @@ -122,6 +126,14 @@ class FakeFfiClient( return homeserverLoginDetailsResult() } + override suspend fun login(username: String, password: String, initialDeviceName: String?, deviceId: String?) { + loginResult(username, password) + } + + override fun newLoginWithQrCodeHandler(oauthConfiguration: OAuthConfiguration): LoginWithQrCodeHandler { + return newLoginWithQrCodeHandlerResult() + } + override suspend fun setMediaRetentionPolicy(policy: MediaRetentionPolicy) {} override suspend fun getStoreSizes(): StoreSizes { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt new file mode 100644 index 0000000000..6e6b750643 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.GeneratedQrLoginProgressListener +import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.QrCodeData +import org.matrix.rustcomponents.sdk.QrLoginProgress +import org.matrix.rustcomponents.sdk.QrLoginProgressListener + +class FakeFfiLoginWithQrCodeHandler( + private val generateResult: suspend () -> Unit = {}, + private val scanResult: suspend (QrCodeData) -> Unit = {}, +) : LoginWithQrCodeHandler(NoHandle) { + private var scanProgressListener: QrLoginProgressListener? = null + + override suspend fun generate(progressListener: GeneratedQrLoginProgressListener) { + generateResult() + } + + override suspend fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener) { + scanProgressListener = progressListener + scanResult(qrCodeData) + } + + fun emitScanProgress(progress: QrLoginProgress) { + scanProgressListener?.onUpdate(progress) + } + + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt index 4070d1b2cd..926cd85d30 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt @@ -14,12 +14,17 @@ import org.matrix.rustcomponents.sdk.QrCodeData class FakeFfiQrCodeData( private val serverNameResult: () -> String? = { lambdaError() }, + private val baseUrlResult: () -> String? = { lambdaError() }, private val toBytesResult: () -> ByteArray = { lambdaError() }, ) : QrCodeData(NoHandle) { override fun serverName(): String? { return serverNameResult() } + override fun baseUrl(): String? { + return baseUrlResult() + } + override fun toBytes(): ByteArray { return toBytesResult() } diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index e33351f2ef..12956e7b29 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 9 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 1 +private const val versionReleaseNumber = 2 object Versions { /** diff --git a/plugins/src/main/kotlin/extension/KoverExtension.kt b/plugins/src/main/kotlin/extension/KoverExtension.kt index 610562ce6a..51b06af7a7 100644 --- a/plugins/src/main/kotlin/extension/KoverExtension.kt +++ b/plugins/src/main/kotlin/extension/KoverExtension.kt @@ -107,13 +107,8 @@ fun Project.setupKover() { // Exclude generated classes. "*_Module", "*_AssistedFactory", - "com.airbnb.android.showkase*", - "io.element.android.libraries.designsystem.showkase.*", "*ComposableSingletons$*", "*BuildConfig", - // Generated by Showkase - "*Ioelementandroid*PreviewKt$*", - "*Ioelementandroid*PreviewKt", // Other // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro) "*Node", diff --git a/settings.gradle.kts b/settings.gradle.kts index db03a32f18..715866b5c4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,7 +25,19 @@ dependencyResolutionManagement { includeModule("com.github.philburk", "jsyn") } } - google() + // Check for official Android-related packages only in Google's maven repo + exclusiveContent { + forRepository { + google() + } + filter { + includeGroupByRegex("android\\..*") + includeGroupByRegex("androidx\\..*") + includeGroupByRegex("com\\.android\\.tools.*") + includeGroupByRegex("com\\.google\\.firebase.*") + includeGroupByRegex("com\\.google\\.android.*") + } + } mavenCentral() maven { url = uri("https://repo1.maven.org/maven2/") diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 14a3f24f3c..cb08d3b630 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -105,6 +105,7 @@ class KonsistClassNameTest { "FakeListenableFuture", "FakeMediaStoreContentProvider", "FakeParentNode", + "FakeSequentialClientBuilderProvider", ) .assertTrue { val interfaceName = it.name diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts index 466167981c..d69cdef35d 100644 --- a/tests/uitests/build.gradle.kts +++ b/tests/uitests/build.gradle.kts @@ -40,8 +40,6 @@ dependencies { ) } - implementation(libs.showkase) - // TODO There is a Resources.NotFoundException maybe due to the mipmap, even if we have // `testOptions { unitTests.isIncludeAndroidResources = true }` in the app build.gradle.kts file // implementation(projects.app) diff --git a/tools/danger/dangerfile-lint.js b/tools/danger/dangerfile-lint.js deleted file mode 100644 index 3023918656..0000000000 --- a/tools/danger/dangerfile-lint.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) 2025 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -import { schedule } from 'danger' - -/** - * Ref and documentation: https://github.com/damian-burke/danger-plugin-lint-report - * This file will check all the error in XML Checkstyle format. - * It covers, lint, ktlint, and detekt errors - */ - -const reporter = require("danger-plugin-lint-report") -schedule(reporter.scan({ - /** - * File mask used to find XML checkstyle reports. - */ - fileMask: "**/reports/**/**.xml", - /** - * If set to true, the severity will be used to switch between the different message formats (message, warn, fail). - */ - reportSeverity: true, - /** - * If set to true, only issues will be reported that are contained in the current changeset (line comparison). - * If set to false, all issues that are in modified files will be reported. - */ - requireLineModification: false, - /** - * Optional: Sets a prefix foreach violation message. - * This can be useful if there are multiple reports being parsed to make them distinguishable. - */ - // outputPrefix?: "" - - /** - * Optional: If set to true, it will remove duplicate violations. - */ - removeDuplicates: true, -})) diff --git a/tools/danger/dangerfile.js b/tools/danger/dangerfile.js deleted file mode 100644 index 634696d432..0000000000 --- a/tools/danger/dangerfile.js +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Copyright (c) 2025 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -const {danger, warn} = require('danger') -const fs = require('fs') -const path = require('path') - -/** - * Note: if you update the checks in this file, please also update the file ./docs/danger.md - */ - -// Useful to see what we got in danger object -// warn(JSON.stringify(danger)) - -const pr = danger.github.pr -const github = danger.github -// User who has created the PR. -const user = pr.user.login -const modified = danger.git.modified_files -const created = danger.git.created_files -const editedFiles = [...modified, ...created] - -// Check that the PR has a description -if (pr.body.length == 0) { - warn("Please provide a description for this PR.") -} - -// Warn when there is a big PR -if (editedFiles.length > 50) { - message("This pull request seems relatively large. Please consider splitting it into multiple smaller ones.") -} - -// Request a correct title for each PR -if (pr.title.endsWith("…")) { - fail("Please provide a complete title that can be used as a changelog entry.") -} - -// Request a `PR-` label for each PR -if (pr.labels.filter((label) => label.name.startsWith("PR-")).length != 1) { - fail("Please add a `PR-` label to categorise the changelog entry.") -} - -// check that frozen classes have not been modified -const frozenClasses = [ -] - -frozenClasses.forEach(frozen => { - if (editedFiles.some(file => file.endsWith(frozen))) { - fail("Frozen class `" + frozen + "` has been modified. Please do not modify frozen class.") - } - } -) - -const previewAnnotations = [ - 'androidx.compose.ui.tooling.preview.Preview', - 'io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight', - 'io.element.android.libraries.designsystem.preview.PreviewsDayNight' -] - -const filesWithPreviews = editedFiles.filter(file => file.endsWith(".kt")).filter(file => { - const content = fs.readFileSync(file); - return previewAnnotations.some((ann) => content.includes("import " + ann)); -}) - -const composablePreviewProviderContents = fs.readFileSync('tests/uitests/src/test/kotlin/base/ComposablePreviewProvider.kt'); -const packageTreesRegex = /private val PACKAGE_TREES = arrayOf\(([\w\W]+?)\n\)/gm; -const packageTreesMatch = packageTreesRegex.exec(composablePreviewProviderContents)[1]; -const scannedPreviewPackageTrees = packageTreesMatch - .replaceAll("\"", "") - .replaceAll(",", "") - .split('\n').map((line) => line.trim()) - .filter((line) => line.length > 0); - -const previewPackagesNotIncludedInScreenshotTests = filesWithPreviews.map((file) => { - const content = fs.readFileSync(file); - const packageRegex = /package\s+([a-zA-Z0-9.]+)/; - const packageMatch = packageRegex.exec(content); - - if (!packageMatch || packageMatch.length != 2) { - return null; - } - - return packageMatch[1]; - - -}).filter((package) => { - if (!package) { - return false; - } - if (!scannedPreviewPackageTrees.some((prefix) => package.includes(prefix))) { - return true; - } -}); - -if (previewPackagesNotIncludedInScreenshotTests.length > 0) { - const packagesList = previewPackagesNotIncludedInScreenshotTests.map((p) => '- `' + p + '`').join("\n"); - warn("You have made changes to a file containing a `@Preview` annotated function but its package name prefix is not included in the `ComposablePreviewProvider`.\nPackages missing in `tests/uitests/src/test/kotlin/base/ComposablePreviewProvider.kt`: \n" + packagesList); -} - -// Check for pngs on resources -const hasPngs = editedFiles.filter(file => { - file.toLowerCase().endsWith(".png") && !file.includes("snapshots/images/") // Exclude screenshots -}).length > 0 -if (hasPngs) { - warn("You seem to have made changes to some images. Please consider using an vector drawable.") -} - -// Check that translations have not been modified by developers -const translationAllowList = [ - "ElementBot", -] - -if (!translationAllowList.includes(user)) { - if (editedFiles.some(file => file.endsWith("translations.xml"))) { - fail("Some translation files have been edited. Only user `ElementBot` (i.e. translations coming from Localazy) is allowed to do that.\nPlease read more about translations management [in the doc](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#strings).") - } -} diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index ec400cbd4c..eda603b879 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202609010' versionName='26.09.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202609020' versionName='26.09.2' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS'