From d0d30dc6e27bd8ae78763b33f0e3d22d6f69e486 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 4 Sep 2026 15:54:02 +0200 Subject: [PATCH 01/22] Replace Danger with actions/github-script The PR metadata checks move to a new PR Checks workflow, and the lint, ktlint and detekt reports are now surfaced as inline annotations from the quality workflow. This removes the danger-js dependency, its ad-hoc npm installs and the need for the ElementBot token, so the checks also run on pull requests from forks. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/danger.yml | 33 ------ .github/workflows/pr-checks.yml | 126 ++++++++++++++++++++++ .github/workflows/quality.yml | 180 ++++++++++++++++++++++++++++---- Gemfile | 3 - build.gradle.kts | 2 +- docs/continuous_integration.md | 2 +- docs/danger.md | 105 ------------------- tools/danger/dangerfile-lint.js | 41 -------- tools/danger/dangerfile.js | 121 --------------------- 9 files changed, 287 insertions(+), 326 deletions(-) delete mode 100644 .github/workflows/danger.yml create mode 100644 .github/workflows/pr-checks.yml delete mode 100644 Gemfile delete mode 100644 docs/danger.md delete mode 100644 tools/danger/dangerfile-lint.js delete mode 100644 tools/danger/dangerfile.js 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 91ba6a6e1d..b3a1921c3e 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/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/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/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).") - } -} From 5ce531fd44905431bf3e2d541eccf9672e767bc7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 28 Aug 2026 17:30:37 +0200 Subject: [PATCH 02/22] Ensure we create a fresh Client when the sessionId is know after authentication. --- .../matrix/impl/RustMatrixClientFactory.kt | 11 +-- .../auth/RustMatrixAuthenticationService.kt | 91 ++++++++----------- 2 files changed, 38 insertions(+), 64 deletions(-) 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 de29e7a953..db391b3fdc 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 @@ -118,14 +117,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)) @@ -136,7 +127,7 @@ class RustMatrixClientFactory( .withProfilesExtension() .finish() - return RustMatrixClient( + RustMatrixClient( sessionPaths = sessionData.getSessionPaths(), innerClient = client, sessionStore = sessionStore, 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..e0758b590d 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() @@ -374,18 +354,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) @@ -400,6 +369,22 @@ class RustMatrixAuthenticationService( } } + private suspend fun finalizeClientCreation(sessionData: SessionData): SessionId { + 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) + + // Clean up the strong reference held here since it's no longer necessary + clear() + + return SessionId(sessionData.userId) + } + private suspend fun makeClient( sessionPaths: SessionPaths, config: suspend ClientBuilder.() -> ClientBuilder, @@ -449,10 +434,8 @@ class RustMatrixAuthenticationService( .build() } - private fun clear(destroyClient: Boolean) { - if (destroyClient) { - currentClient?.close() - } + private fun clear() { + currentClient?.close() currentClient = null } From 1f18a18917763218e809d7e54ff66ee4b36823ca Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 4 Sep 2026 17:08:10 +0200 Subject: [PATCH 03/22] Iterate on hooks implementation --- enterprise | 2 +- .../api/ClientBuilderEnterpriseHook.kt | 21 ++++++++++++++-- .../DefaultClientBuilderEnterpriseHook.kt | 7 +++++- .../test/FakeClientBuilderEnterpriseHook.kt | 25 +++++++++++++++++++ .../matrix/impl/RustMatrixClientFactory.kt | 7 +++++- .../impl/RustMatrixClientFactoryTest.kt | 3 ++- .../RustTemporaryMatrixClientFactoryTest.kt | 3 ++- 7 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeClientBuilderEnterpriseHook.kt diff --git a/enterprise b/enterprise index bb07b4f839..10b18f1416 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit bb07b4f839163034fab9c3d1b4ae364f0839b450 +Subproject commit 10b18f141697b8a3998022b1cb7ff9f4a9f1171c 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..f60b647c5d 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 tweakClientBuilder(clientBuilder: MatrixClientBuilder): MatrixClientBuilder + + /** + * Customize the [MatrixClientBuilder] for enterprise features. + * This method is invoked 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 tweakClientBuilder(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..e4b6df4171 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 tweakClientBuilder(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { + // No modification + return clientBuilder + } + + override suspend fun tweakClientBuilder(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..8a8c3c5a67 --- /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 tweakClientBuilderResult: (MatrixClientBuilder) -> MatrixClientBuilder = { it }, + private val tweakClientBuilderWithSessionIdResult: (MatrixClientBuilder, SessionId) -> MatrixClientBuilder = { clientBuilder, _ -> clientBuilder }, +) : ClientBuilderEnterpriseHook { + override suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { + return tweakClientBuilderResult(clientBuilder) + } + + override suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + return tweakClientBuilderWithSessionIdResult(clientBuilder, sessionId) + } +} 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 db391b3fdc..8f07b3c546 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 @@ -99,7 +99,9 @@ class RustMatrixClientFactory( ) .homeserverUrl(sessionData.homeserverUrl) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) - .let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } + .let { + (clientBuilderEnterpriseHook.tweakClientBuilder(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner + } .use { it.build() } client.setMediaRetentionPolicy( @@ -219,6 +221,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.tweakClientBuilder(RustMatrixClientBuilder(it)) as RustMatrixClientBuilder).inner + } } } 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..6b5b5a154a 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,6 +9,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.api.core.SessionId import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider @@ -65,5 +66,5 @@ fun TestScope.createRustMatrixClientFactory( 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/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(), ) } From 579ec9bd6a732d46ef12e42121f3c1bb614f05d4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 4 Sep 2026 17:23:29 +0200 Subject: [PATCH 04/22] Close the client used to log in before creating the client of the session Since the login flows stopped handing their client over to RustMatrixClientFactory and let finalizeClientCreation() build a fresh one from the SessionData, two problems appeared. The client built by loginWithQrCode() was never owned by anyone: it is not assigned to currentClient, so clear() had nothing to close and the Rust Client stayed open for the whole app lifetime, holding the session stores of the freshly logged in account. It is now tracked in currentClient, and also disposed of when the QR login fails, which is safe because that flow always builds its own client. The client of the session was also built while the login client was still open. Both use the same session paths, so their state and crypto SQLite stores, and the search index when message search is enabled, were opened twice at the same time with no cross process lock to protect them. clear() now runs before the final client is created. Co-Authored-By: Claude Opus 5 (1M context) --- .../auth/RustMatrixAuthenticationService.kt | 10 +- .../RustMatrixAuthenticationServiceTest.kt | 106 +++++++++++++++++- .../impl/fixtures/fakes/FakeFfiClient.kt | 12 ++ .../fakes/FakeFfiLoginWithQrCodeHandler.kt | 37 ++++++ .../impl/fixtures/fakes/FakeFfiQrCodeData.kt | 5 + 5 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt 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 e0758b590d..771bc60a05 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 @@ -337,6 +337,7 @@ class RustMatrixAuthenticationService( sessionPaths = emptySessionPaths, qrCodeData = sdkQrCodeLoginData, ) + currentClient = client client.newLoginWithQrCodeHandler( oauthConfiguration = oAuthConfiguration, ).use { @@ -362,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 } @@ -370,6 +373,10 @@ 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) @@ -379,9 +386,6 @@ class RustMatrixAuthenticationService( newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) - // Clean up the strong reference held here since it's no longer necessary - clear() - return SessionId(sessionData.userId) } 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..fb4fb80e1e 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 @@ -14,20 +14,29 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.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.Test +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.HumanQrLoginException import java.io.File class RustMatrixAuthenticationServiceTest { @@ -74,8 +83,102 @@ 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++]) + } + private fun TestScope.createRustMatrixAuthenticationService( - sessionStore: SessionStore = InMemorySessionStore(), + sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), enterpriseService: EnterpriseService = FakeEnterpriseService(), ): RustMatrixAuthenticationService { @@ -85,6 +188,7 @@ class RustMatrixAuthenticationServiceTest { cacheDirectory = cacheDirectory, sessionStore = sessionStore, clientBuilderProvider = clientBuilderProvider, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), ) return RustMatrixAuthenticationService( sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory), 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() } From 59e72fc3ee3167b99cb02f7bed5726fda4c75fa1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 4 Sep 2026 17:28:34 +0200 Subject: [PATCH 05/22] Stop deleting the session data of a logged in account on the next login sessionPaths keeps pointing at the directories of the account which has just been logged in, and it was never reset. Adding another account calls setHomeserver(), whose rotateSessionPath() starts by deleting the directories of the previous sessionPaths, so the file and cache directories of the first account were wiped. finalizeClientCreation() now forgets the session paths once the login succeeded. It is done there rather than in clear() on purpose: after a failed attempt the directories are orphaned and must still be deleted by the rotateSessionPath() of the next attempt. Co-Authored-By: Claude Opus 5 (1M context) --- .../auth/RustMatrixAuthenticationService.kt | 4 + .../impl/RustMatrixClientFactoryTest.kt | 4 +- .../RustMatrixAuthenticationServiceTest.kt | 86 ++++++++++++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) 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 771bc60a05..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 @@ -386,6 +386,10 @@ class RustMatrixAuthenticationService( 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) } 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 6b5b5a154a..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 @@ -15,6 +15,7 @@ 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 @@ -52,6 +53,7 @@ fun TestScope.createRustMatrixClientFactory( ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -64,7 +66,7 @@ fun TestScope.createRustMatrixClientFactory( featureFlagService = FakeFeatureFlagService(), timelineEventFilterFactory = FakeTimelineEventFilterFactory(), clientBuilderProvider = clientBuilderProvider, - sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), + sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, workManagerScheduler = workManagerScheduler, 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 fb4fb80e1e..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,6 +12,7 @@ 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 @@ -22,6 +23,10 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserver 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 @@ -33,13 +38,18 @@ 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( @@ -177,21 +187,89 @@ class RustMatrixAuthenticationServiceTest { 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(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, From a817b6f219a1b9a3b8acd20065a858e4616a7ed4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 8 Sep 2026 17:06:53 +0200 Subject: [PATCH 06/22] Rename methods and update documentation. --- enterprise | 2 +- .../enterprise/api/ClientBuilderEnterpriseHook.kt | 6 +++--- .../impl/DefaultClientBuilderEnterpriseHook.kt | 4 ++-- .../test/FakeClientBuilderEnterpriseHook.kt | 12 ++++++------ .../libraries/matrix/impl/RustMatrixClientFactory.kt | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/enterprise b/enterprise index 10b18f1416..ebe8b6a108 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 10b18f141697b8a3998022b1cb7ff9f4a9f1171c +Subproject commit ebe8b6a108dcc823b7d123b94f00c11ed2d62d88 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 f60b647c5d..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 @@ -21,15 +21,15 @@ interface ClientBuilderEnterpriseHook { * @param clientBuilder The [MatrixClientBuilder] to customize. * @return The customized [MatrixClientBuilder]. */ - suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder): MatrixClientBuilder + suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder /** * Customize the [MatrixClientBuilder] for enterprise features. - * This method is invoked when a new [MatrixClientBuilder] is created to build a client for a specific session. + * 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 tweakClientBuilder(clientBuilder: MatrixClientBuilder, sessionId: SessionId): 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 e4b6df4171..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,12 +18,12 @@ import io.element.android.libraries.matrix.api.core.SessionId */ @ContributesBinding(AppScope::class) class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { - override suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { + override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { // No modification return clientBuilder } - override suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + 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 index 8a8c3c5a67..3781f25382 100644 --- 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 @@ -12,14 +12,14 @@ import io.element.android.libraries.matrix.api.MatrixClientBuilder import io.element.android.libraries.matrix.api.core.SessionId class FakeClientBuilderEnterpriseHook( - private val tweakClientBuilderResult: (MatrixClientBuilder) -> MatrixClientBuilder = { it }, - private val tweakClientBuilderWithSessionIdResult: (MatrixClientBuilder, SessionId) -> MatrixClientBuilder = { clientBuilder, _ -> clientBuilder }, + private val beforeClientCreationResult: (MatrixClientBuilder) -> MatrixClientBuilder = { it }, + private val beforeClientCreationWithSessionResult: (MatrixClientBuilder, SessionId) -> MatrixClientBuilder = { clientBuilder, _ -> clientBuilder }, ) : ClientBuilderEnterpriseHook { - override suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { - return tweakClientBuilderResult(clientBuilder) + override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder { + return beforeClientCreationResult(clientBuilder) } - override suspend fun tweakClientBuilder(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { - return tweakClientBuilderWithSessionIdResult(clientBuilder, sessionId) + override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { + return beforeClientCreationWithSessionResult(clientBuilder, sessionId) } } 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 8f07b3c546..16f33d91db 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 @@ -100,7 +100,7 @@ class RustMatrixClientFactory( .homeserverUrl(sessionData.homeserverUrl) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) .let { - (clientBuilderEnterpriseHook.tweakClientBuilder(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner + (clientBuilderEnterpriseHook.beforeClientCreationWithSession(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } .use { it.build() } @@ -222,7 +222,7 @@ class RustMatrixClientFactory( proxyProvider.provides()?.let { proxy(it) } ?: this } .let { - (clientBuilderEnterpriseHook.tweakClientBuilder(RustMatrixClientBuilder(it)) as RustMatrixClientBuilder).inner + (clientBuilderEnterpriseHook.beforeClientCreation(RustMatrixClientBuilder(it)) as RustMatrixClientBuilder).inner } } } From 3cde1523b4e51164cbeeb65ccffabecb80219a5f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 8 Sep 2026 17:47:53 +0200 Subject: [PATCH 07/22] Fix quality and tests. --- .../android/libraries/matrix/impl/RustMatrixClientFactory.kt | 5 ++++- .../io/element/android/tests/konsist/KonsistClassNameTest.kt | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) 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 16f33d91db..b3113c9e05 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 @@ -100,7 +100,10 @@ class RustMatrixClientFactory( .homeserverUrl(sessionData.homeserverUrl) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) .let { - (clientBuilderEnterpriseHook.beforeClientCreationWithSession(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner + (clientBuilderEnterpriseHook.beforeClientCreationWithSession( + clientBuilder = RustMatrixClientBuilder(it), + sessionId = SessionId(sessionData.userId), + ) as RustMatrixClientBuilder).inner } .use { it.build() } 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 From 1ee156f480cc96c87c77ab9930781ab9429a8af2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 9 Sep 2026 15:56:25 +0200 Subject: [PATCH 08/22] Remove Showkase from the project. --- .idea/dictionaries/shared.xml | 1 - docs/_developer_onboarding.md | 8 ++++---- features/preferences/impl/build.gradle.kts | 1 - .../impl/developer/DeveloperSettingsNode.kt | 10 ---------- .../impl/developer/DeveloperSettingsView.kt | 3 --- .../appsettings/AppDeveloperSettingsNode.kt | 10 ---------- .../appsettings/AppDeveloperSettingsPage.kt | 3 --- .../appsettings/AppDeveloperSettingsView.kt | 10 ---------- .../developer/DeveloperSettingsViewTest.kt | 17 ----------------- .../AppDeveloperSettingsPageTest.kt | 17 ----------------- gradle/libs.versions.toml | 4 ---- libraries/compound/build.gradle.kts | 1 - .../showkase/CompoundShowkaseRootModule.kt | 15 --------------- .../compound/tokens/CompoundTypography.kt | 18 +----------------- libraries/designsystem/build.gradle.kts | 10 ---------- libraries/designsystem/consumer-rules.keep | 5 ----- .../designsystem/preview/WithFontScale.kt | 4 ++-- .../showkase/DesignSystemShowkaseRootModule.kt | 15 --------------- .../main/kotlin/extension/KoverExtension.kt | 5 ----- tests/uitests/build.gradle.kts | 2 -- 20 files changed, 7 insertions(+), 152 deletions(-) delete mode 100644 libraries/compound/src/main/kotlin/io/element/android/compound/showkase/CompoundShowkaseRootModule.kt delete mode 100644 libraries/designsystem/consumer-rules.keep delete mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/showkase/DesignSystemShowkaseRootModule.kt 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/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/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 d98f00b3a0..ede969506c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,8 +40,6 @@ 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" # 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" @@ -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" 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/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/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) From 666081e2f203734a1612c4fe0b5b42bac251b82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 9 Sep 2026 12:41:39 +0200 Subject: [PATCH 09/22] Adjust concurrent requests for renovate --- .github/renovate.json5 | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index be8bde70c3..8b06b2ba98 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", From 10776a7e6babea2968b374807ba5773a46453fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 9 Sep 2026 12:41:59 +0200 Subject: [PATCH 10/22] Make sure Android dependencies are fetched from Google's repo in Gradle sync --- settings.gradle.kts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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/") From 0b7d0897915bc0e7985887bb32f2448d64037b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 9 Sep 2026 12:43:29 +0200 Subject: [PATCH 11/22] Make Renovate only look for Element dependencies in maven central --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8b06b2ba98..fc3e8bc84d 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -27,6 +27,7 @@ "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/" ], }, { "groupName": "kotlin", From 5f499ce4b3e0089f67825970c207d86f4fa5cfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 9 Sep 2026 13:43:56 +0200 Subject: [PATCH 12/22] Ensure official Android dependencies are checked only against Google's Android repo --- .github/renovate.json5 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index fc3e8bc84d..8b200dd8f6 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -29,6 +29,15 @@ "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/", + ], + "registryUrls": [ "https://dl.google.com/android/maven2/" ], + }, { "groupName": "kotlin", "matchPackageNames": [ From c623105da65bedc92b5783cb690add22fe0ba6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 9 Sep 2026 17:52:38 +0200 Subject: [PATCH 13/22] Make the google-repo-only dependencies on renovate match those in gradle --- .github/renovate.json5 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8b200dd8f6..85377d905c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -35,6 +35,8 @@ "/^androidx\\./", "/^android\\./", "/^com\\.android\\.tools/", + "/^com\\.google\\.firebase/", + "/^com\\.google\\.android/", ], "registryUrls": [ "https://dl.google.com/android/maven2/" ], }, From 45ad16d17d537c047c619c6eb738af656024d568 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:10:32 +0000 Subject: [PATCH 14/22] Update dependency org.matrix.rustcomponents:sdk-android to v26.09.9 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ede969506c..300f915b4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -184,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" } From 24a257bd38c087471f327c13545305364e7f1763 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 10 Sep 2026 21:51:49 +0200 Subject: [PATCH 15/22] Setting version for the release 26.09.2 --- plugins/src/main/kotlin/Versions.kt | 2 +- tools/manifest/gplay/release/aaptDump.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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' From 3e08454ff85df9a5d2a24693eba0300d39a0404d Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 10 Sep 2026 21:51:59 +0200 Subject: [PATCH 16/22] Adding fastlane file for version 26.09.2 --- fastlane/metadata/android/en-US/changelogs/202609020.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202609020.txt 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 From 26578c79a90220e53bb5c5a0a9422934c6f9e394 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 10 Sep 2026 22:24:50 +0200 Subject: [PATCH 17/22] Changelog for version 26.09.2 --- CHANGES.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) 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 ============================= From a5b95a18b291be4c4d70052bf42df9c0dae10652 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:19:03 +0200 Subject: [PATCH 18/22] Update dependency io.sentry:sentry-android to v8.55.0 (#7685) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 300f915b4d..6b373a8d8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -226,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" From cbfef6a321e42cc83ef25312f2f705217621f40a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 11 Sep 2026 12:35:45 +0200 Subject: [PATCH 19/22] Update ref --- enterprise | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise b/enterprise index ebe8b6a108..f463003f68 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit ebe8b6a108dcc823b7d123b94f00c11ed2d62d88 +Subproject commit f463003f6813ad33fc00f80786bc83b6a7f0d8e2 From d2c49bf4af740266b6ef6bc79e54e092a5d8764d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:53:11 +0200 Subject: [PATCH 20/22] Update coil to v3.6.2 (#7693) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b373a8d8e..6602dbdf14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ datetime = "0.8.0" serialization_json = "1.11.0" #other -coil = "3.6.1" +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" From 8cd3750cde65c8bbfc97cb578d00111b6bf62ee8 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 11 Sep 2026 17:36:38 +0200 Subject: [PATCH 21/22] Make global search use a `Joined` filter for room results (#7695) Before this, global search returned also invites, which wasn't planned. --- .../features/home/impl/search/GlobalSearchPresenter.kt | 10 +++++++++- .../home/impl/search/RoomListSearchDataSource.kt | 8 ++++++-- .../home/impl/search/RoomListSearchPresenter.kt | 2 +- .../home/impl/search/GlobalSearchPresenterTest.kt | 6 ++++++ .../libraries/matrix/api/roomlist/RoomListFilter.kt | 5 +++++ .../matrix/impl/roomlist/RoomListFilterMapper.kt | 2 ++ 6 files changed, 29 insertions(+), 4 deletions(-) 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/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/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 } } From 3d198d8065030820f0ad2ed06fff65d6928112f3 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 14 Sep 2026 15:19:14 +0200 Subject: [PATCH 22/22] Fix flaky test in `TimelinePresenterTest` (#7702) The problem seemed to be sometimes the background work to switch from live timeline to a focused event timeline wasn't finished in time and an assertion failed from time to time. By providing the coroutine to run this on we can pass a `TestScope` and force it to reliably run immediately instead in unit tests. --- .../features/messages/impl/MessagesNode.kt | 9 +++- .../impl/threads/ThreadedMessagesNode.kt | 9 +++- .../impl/timeline/TimelineController.kt | 8 +++- .../messages/impl/MessagesPresenterTest.kt | 7 ++- ...essageComposerPresenterSlashCommandTest.kt | 8 +++- .../MessageComposerPresenterTest.kt | 8 +++- .../impl/timeline/TimelineControllerTest.kt | 43 +++++++++++++++---- .../impl/timeline/TimelinePresenterTest.kt | 13 +++++- 8 files changed, 87 insertions(+), 18 deletions(-) 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() },