Merge branch 'develop' into renovate/org.sonarqube-7.x

This commit is contained in:
ganfra
2026-09-14 17:30:21 +02:00
committed by GitHub
62 changed files with 860 additions and 592 deletions
+15 -9
View File
@@ -13,18 +13,12 @@
"hostRules": [
{
"matchHost": "https://repo1.maven.org/maven2/",
"concurrentRequestLimit": 4,
"maxRequestsPerSecond": 4,
"concurrentRequestLimit": 10,
"maxRequestsPerSecond": 10,
"timeout": 10000,
}
],
"packageRules": [
{
"matchPackageNames": [
"/^androidx.\.*/"
],
"groupName": "androidx",
"registryUrls": [ "https://dl.google.com/android/maven2/" ],
},
{
"matchPackageNames": [
"org.matrix.rustcomponents:sdk-android",
@@ -33,6 +27,18 @@
"minimumReleaseAge": null,
// Max default value is 6, with this we give absolute priority to our own dependencies
"prPriority": 7,
"registryUrls": [ "https://repo1.maven.org/maven2/" ],
},
{
// Fetch Android dependencies from Google's Maven repo instead of Maven Central.
"matchPackageNames": [
"/^androidx\\./",
"/^android\\./",
"/^com\\.android\\.tools/",
"/^com\\.google\\.firebase/",
"/^com\\.google\\.android/",
],
"registryUrls": [ "https://dl.google.com/android/maven2/" ],
},
{
"groupName": "kotlin",
-33
View File
@@ -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 }}
+126
View File
@@ -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 = "<!-- pr-checks -->";
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.`);
}
+159 -21
View File
@@ -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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
.replace(/&amp;/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/<repo>/<repo>/<path>.
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:
// <file name="…"><error line=".." column=".." severity=".." message=".." source=".."/></file>
function parseCheckstyle(content) {
const issues = [];
const fileRegex = /<file\s+([^>]*?)(\/>|>([\s\S]*?)<\/file>)/g;
let file;
while ((file = fileRegex.exec(content)) !== null) {
const path = attribute(file[1], "name");
if (!path) continue;
const errorRegex = /<error\s+([^>]*?)\/?>/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:
// <issue id=".." severity=".." message=".."><location file=".." line=".." column=".."/></issue>
function parseAndroidLint(content) {
const issues = [];
const issueRegex = /<issue\s+([^>]*?)(\/>|>([\s\S]*?)<\/issue>)/g;
let issue;
while ((issue = issueRegex.exec(content)) !== null) {
const location = /<location\s+([^>]*?)\/?>/.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("<checkstyle")) {
issues.push(...parseCheckstyle(content));
} else if (content.includes("<issues")) {
issues.push(...parseAndroidLint(content));
}
}
// Only report on the files touched by the pull request.
const pr = context.payload.pull_request;
if (pr) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number
});
const changed = new Set(files.map(file => 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();
-1
View File
@@ -16,7 +16,6 @@
<w>posthog</w>
<w>rageshake</w>
<w>securebackup</w>
<w>showkase</w>
<w>snackbar</w>
<w>spdx</w>
<w>swipeable</w>
+60
View File
@@ -1,3 +1,63 @@
Changes in Element X v26.09.2
=============================
<!-- Release notes generated using configuration in .github/release.yml at 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
=============================
-3
View File
@@ -1,3 +0,0 @@
source "https://rubygems.org"
gem 'danger'
+1 -1
View File
@@ -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/"
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
-105
View File
@@ -1,105 +0,0 @@
## Danger
<!--- TOC -->
* [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)
<!--- END -->
## 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 <PR_URL> --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 <PR_URL> --dangerfile=./tools/danger/dangerfile.js
bundle exec danger-kotlin pr <PR_URL> --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
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -13,6 +13,23 @@ import io.element.android.libraries.matrix.api.core.SessionId
/**
* A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
*/
fun interface ClientBuilderEnterpriseHook {
suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
interface ClientBuilderEnterpriseHook {
/**
* Customize the [MatrixClientBuilder] for enterprise features.
* This method is invoked everytime a new [MatrixClientBuilder] is created.
*
* @param clientBuilder The [MatrixClientBuilder] to customize.
* @return The customized [MatrixClientBuilder].
*/
suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder
/**
* Customize the [MatrixClientBuilder] for enterprise features.
* This method is invoked as well as the other method when a new [MatrixClientBuilder] is created to build a client for a specific session.
*
* @param clientBuilder The [MatrixClientBuilder] to customize.
* @param sessionId The [SessionId] for which the [MatrixClientBuilder] is being created.
* @return The customized [MatrixClientBuilder].
*/
suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
}
@@ -18,7 +18,12 @@ import io.element.android.libraries.matrix.api.core.SessionId
*/
@ContributesBinding(AppScope::class)
class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook {
override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder {
// No modification
return clientBuilder
}
override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
// No modification
return clientBuilder
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test
import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
import io.element.android.libraries.matrix.api.MatrixClientBuilder
import io.element.android.libraries.matrix.api.core.SessionId
class FakeClientBuilderEnterpriseHook(
private val beforeClientCreationResult: (MatrixClientBuilder) -> MatrixClientBuilder = { it },
private val beforeClientCreationWithSessionResult: (MatrixClientBuilder, SessionId) -> MatrixClientBuilder = { clientBuilder, _ -> clientBuilder },
) : ClientBuilderEnterpriseHook {
override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder {
return beforeClientCreationResult(clientBuilder)
}
override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
return beforeClientCreationWithSessionResult(clientBuilder, sessionId)
}
}
@@ -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") }
@@ -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)
}
@@ -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) {
@@ -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 }
@@ -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<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),
@@ -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<MessagesState> {
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,
@@ -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<Timeline>>(Optional.empty())
@@ -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,
@@ -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,
@@ -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,
@@ -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(),
)
}
@@ -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() },
@@ -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)
@@ -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) },
@@ -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 = {},
)
@@ -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) },
)
@@ -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 = {},
)
}
@@ -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 = {},
)
}
@@ -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<DeveloperSettingsEvent>(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<ComponentActivity>.setDeveloperSettingsView(
state: DeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onPushHistoryClick: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled()
) {
setContent {
DeveloperSettingsView(
state = state,
onOpenShowkase = onOpenShowkase,
onPushHistoryClick = onPushHistoryClick,
onBackClick = onBackClick,
)
@@ -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<AppDeveloperSettingsEvent>(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<ComponentActivity>.setAppDeveloperSettingsView(
state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AppDeveloperSettingsPage(
state = state,
onOpenShowkase = onOpenShowkase,
onBackClick = onBackClick,
)
}
+3 -7
View File
@@ -39,9 +39,7 @@ datetime = "0.8.0"
serialization_json = "1.11.0"
#other
coil = "3.6.1"
# Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420
showkase = "1.0.5"
coil = "3.6.2"
# There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs.
# When upgrading this version, check state restoration still works fine.
appyx = "1.7.1"
@@ -186,7 +184,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.08"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.09.9"
# Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -198,8 +196,6 @@ coil_test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coil" }
datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" }
kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.2"
showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" }
showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" }
jsoup = "org.jsoup:jsoup:1.23.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0"
@@ -230,7 +226,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics
posthog = "com.posthog:posthog-android:3.60.7"
sentry = "io.sentry:sentry-android:8.54.0"
sentry = "io.sentry:sentry-android:8.55.0"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0"
-1
View File
@@ -22,7 +22,6 @@ android {
}
dependencies {
implementation(libs.showkase)
testCommonDependencies(libs)
testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose)
@@ -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
@@ -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,
)
-10
View File
@@ -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)
}
@@ -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 {
<init>();
}
@@ -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) {
@@ -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
@@ -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.
*/
@@ -35,7 +35,6 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig
@@ -100,7 +99,12 @@ class RustMatrixClientFactory(
)
.homeserverUrl(sessionData.homeserverUrl)
.enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination))
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
.let {
(clientBuilderEnterpriseHook.beforeClientCreationWithSession(
clientBuilder = RustMatrixClientBuilder(it),
sessionId = SessionId(sessionData.userId),
) as RustMatrixClientBuilder).inner
}
.use { it.build() }
client.setMediaRetentionPolicy(
@@ -118,14 +122,6 @@ class RustMatrixClientFactory(
client.restoreSession(sessionData.toSession())
create(client, sessionData, isMessageSearchAvailable)
}
suspend fun create(
client: Client,
sessionData: SessionData,
isMessageSearchAvailable: Boolean,
): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
client.setUtdDelegate(UtdTracker(analyticsService))
@@ -135,7 +131,7 @@ class RustMatrixClientFactory(
.withOfflineMode()
.finish()
return RustMatrixClient(
RustMatrixClient(
sessionPaths = sessionData.getSessionPaths(),
innerClient = client,
sessionStore = sessionStore,
@@ -227,6 +223,9 @@ class RustMatrixClientFactory(
// Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep
proxyProvider.provides()?.let { proxy(it) } ?: this
}
.let {
(clientBuilderEnterpriseHook.beforeClientCreation(RustMatrixClientBuilder(it)) as RustMatrixClientBuilder).inner
}
}
}
@@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.impl.keys.SecretGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
@@ -101,17 +102,7 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val sessionData = sessionStore.getSession(sessionId.value)
if (sessionData != null) {
if (sessionData.isTokenValid) {
// Use the sessionData.passphrase, which can be null for a previously created session
if (sessionData.passphrase == null) {
Timber.w("Restoring a session without a passphrase")
} else {
Timber.w("Restoring a session with a passphrase")
}
rustMatrixClientFactory.create(sessionData)
} else {
throw SessionRestorationException.InvalidToken()
}
restoreSession(sessionData)
} else {
throw SessionRestorationException.MissingSession(sessionId)
}
@@ -120,6 +111,19 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun restoreSession(sessionData: SessionData): MatrixClient {
if (!sessionData.isTokenValid) {
throw SessionRestorationException.InvalidToken()
}
// Use the sessionData.passphrase, which can be null for a previously created session
if (sessionData.passphrase == null) {
Timber.w("Restoring a session without a passphrase")
} else {
Timber.w("Restoring a session with a passphrase")
}
return rustMatrixClientFactory.create(sessionData)
}
private fun getDatabaseKey(): ClientSecret {
Timber.d("New sessions will be encrypted with a raw key")
return secretGenerator.generateKey()
@@ -137,7 +141,7 @@ class RustMatrixAuthenticationService(
client.homeserverLoginDetails().map()
}.onFailure {
clear(destroyClient = true)
clear()
}.mapFailure { failure ->
Timber.e(failure, "Failed to set homeserver to $homeserver")
failure.mapAuthenticationException()
@@ -165,18 +169,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
finalizeClientCreation(sessionData)
}.mapFailure { failure ->
Timber.e(failure, "Failed to login")
failure.mapAuthenticationException()
@@ -305,20 +298,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
finalizeClientCreation(sessionData)
}.mapFailure { failure ->
Timber.e(failure, "Failed to login with OAuth")
failure.mapAuthenticationException()
@@ -357,6 +337,7 @@ class RustMatrixAuthenticationService(
sessionPaths = emptySessionPaths,
qrCodeData = sdkQrCodeLoginData,
)
currentClient = client
client.newLoginWithQrCodeHandler(
oauthConfiguration = oAuthConfiguration,
).use {
@@ -374,18 +355,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = emptySessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
finalizeClientCreation(sessionData)
}.mapFailure {
when (it) {
is QrCodeDecodeException -> QrErrorMapper.map(it)
@@ -393,6 +363,8 @@ class RustMatrixAuthenticationService(
else -> it
}
}.onFailure { throwable ->
// A QR code login always builds its own client, so it can be disposed of on failure.
clear()
if (throwable is CancellationException) {
throw throwable
}
@@ -400,6 +372,27 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun finalizeClientCreation(sessionData: SessionData): SessionId {
// Close the client which was used to perform the login before creating the final client.
// Both use the same session paths, so their SQLite stores must never be opened at the same time.
clear()
val matrixClient = restoreSession(sessionData)
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// The session paths now hold the data of the account which has just been logged in, so forget
// them: they must not be deleted by the rotateSessionPath() of the next login attempt.
sessionPaths = null
return SessionId(sessionData.userId)
}
private suspend fun makeClient(
sessionPaths: SessionPaths,
config: suspend ClientBuilder.() -> ClientBuilder,
@@ -449,10 +442,8 @@ class RustMatrixAuthenticationService(
.build()
}
private fun clear(destroyClient: Boolean) {
if (destroyClient) {
currentClient?.close()
}
private fun clear() {
currentClient?.close()
currentClient = null
}
@@ -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
}
}
@@ -9,11 +9,13 @@
package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@@ -51,6 +53,7 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -63,7 +66,7 @@ fun TestScope.createRustMatrixClientFactory(
featureFlagService = FakeFeatureFlagService(),
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
workManagerScheduler = workManagerScheduler,
clientBuilderEnterpriseHook = { builder, _ -> builder },
clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
)
@@ -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(),
)
}
@@ -12,25 +12,44 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiLoginWithQrCodeHandler
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilder
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilder
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.HumanQrLoginException
import java.io.File
class RustMatrixAuthenticationServiceTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `setHomeserver is successful`() = runTest {
val sut = createRustMatrixAuthenticationService(
@@ -74,20 +93,183 @@ class RustMatrixAuthenticationServiceTest {
closeResult.assertions().isCalledOnce()
}
@Test
fun `login closes the client used to log in before building the client of the session`() = runTest {
val events = mutableListOf<String>()
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<String>()
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<Unit> {}
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = {
FakeFfiClientBuilder(
buildResult = {
FakeFfiClient(
newLoginWithQrCodeHandlerResult = {
FakeFfiLoginWithQrCodeHandler(
scanResult = { throw HumanQrLoginException.Unknown() },
)
},
closeResult = closeResult,
)
},
)
},
),
)
assertThat(sut.loginWithQrCode(aSdkQrCodeLoginData()) {}.isFailure).isTrue()
closeResult.assertions().isCalledOnce()
}
private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData(
FakeFfiQrCodeData(
baseUrlResult = { A_HOMESERVER_URL },
)
)
/**
* A [ClientBuilderProvider] handing out one [Client] per call, in order.
*/
private class FakeSequentialClientBuilderProvider(
private vararg val clients: () -> Client,
) : ClientBuilderProvider {
private var index = 0
override fun provide(): ClientBuilder = FakeFfiClientBuilder(buildResult = clients[index++])
}
@Test
fun `a new login does not delete the session data of the account which has just been logged in`() = runTest {
val storeBuilderProvider = SessionDirectoryCreatingSqliteStoreBuilderProvider()
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeSequentialClientBuilderProvider(
{ aLoginFakeFfiClient() },
{ FakeFfiClient(withUtdHook = {}) },
{ aLoginFakeFfiClient() },
),
sessionPathsFactory = SessionPathsFactory(temporaryFolder.newFolder("base"), temporaryFolder.newFolder("cache")),
sqliteStoreBuilderProvider = storeBuilderProvider,
)
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(sut.login("alice", "password").getOrNull()).isEqualTo(A_USER_ID)
val loggedInSessionPaths = storeBuilderProvider.providedSessionPaths.first()
// Adding another account rotates the session paths, which must not touch the previous account.
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(loggedInSessionPaths.fileDirectory.exists()).isTrue()
assertThat(loggedInSessionPaths.cacheDirectory.exists()).isTrue()
}
@Test
fun `a new login deletes the session data of a previous failed login attempt`() = runTest {
val storeBuilderProvider = SessionDirectoryCreatingSqliteStoreBuilderProvider()
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeSequentialClientBuilderProvider(
{ aLoginFakeFfiClient() },
{ throw IllegalStateException("Failed to build the client of the session") },
{ aLoginFakeFfiClient() },
),
sessionPathsFactory = SessionPathsFactory(temporaryFolder.newFolder("base"), temporaryFolder.newFolder("cache")),
sqliteStoreBuilderProvider = storeBuilderProvider,
)
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(sut.login("alice", "password").isFailure).isTrue()
val abandonedSessionPaths = storeBuilderProvider.providedSessionPaths.first()
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(abandonedSessionPaths.fileDirectory.exists()).isFalse()
assertThat(abandonedSessionPaths.cacheDirectory.exists()).isFalse()
}
private fun aLoginFakeFfiClient() = FakeFfiClient(
homeserverLoginDetailsResult = { FakeFfiHomeserverLoginDetails() },
loginResult = { _, _ -> },
)
/**
* A [SqliteStoreBuilderProvider] which creates the session directories, like the SDK does when it
* opens its stores, and records them so that a test can assert on their lifecycle.
*/
private class SessionDirectoryCreatingSqliteStoreBuilderProvider : SqliteStoreBuilderProvider {
val providedSessionPaths = mutableListOf<SessionPaths>()
override fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder {
sessionPaths.fileDirectory.mkdirs()
sessionPaths.cacheDirectory.mkdirs()
providedSessionPaths.add(sessionPaths)
return FakeSqliteStoreBuilder()
}
}
private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(),
sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
): RustMatrixAuthenticationService {
val baseDirectory = File("/base")
val cacheDirectory = File("/cache")
val rustMatrixClientFactory = createRustMatrixClientFactory(
cacheDirectory = cacheDirectory,
cacheDirectory = File("/cache"),
sessionStore = sessionStore,
clientBuilderProvider = clientBuilderProvider,
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
)
return RustMatrixAuthenticationService(
sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory),
sessionPathsFactory = sessionPathsFactory,
coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory,
@@ -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 {
@@ -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
}
@@ -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()
}
+1 -1
View File
@@ -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 {
/**
@@ -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",
+13 -1
View File
@@ -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/")
@@ -105,6 +105,7 @@ class KonsistClassNameTest {
"FakeListenableFuture",
"FakeMediaStoreContentProvider",
"FakeParentNode",
"FakeSequentialClientBuilderProvider",
)
.assertTrue {
val interfaceName = it.name
-2
View File
@@ -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)
-41
View File
@@ -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,
}))
-121
View File
@@ -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).")
}
}
+1 -1
View File
@@ -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'