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": [ "hostRules": [
{ {
"matchHost": "https://repo1.maven.org/maven2/", "matchHost": "https://repo1.maven.org/maven2/",
"concurrentRequestLimit": 4, "concurrentRequestLimit": 10,
"maxRequestsPerSecond": 4, "maxRequestsPerSecond": 10,
"timeout": 10000,
} }
], ],
"packageRules": [ "packageRules": [
{
"matchPackageNames": [
"/^androidx.\.*/"
],
"groupName": "androidx",
"registryUrls": [ "https://dl.google.com/android/maven2/" ],
},
{ {
"matchPackageNames": [ "matchPackageNames": [
"org.matrix.rustcomponents:sdk-android", "org.matrix.rustcomponents:sdk-android",
@@ -33,6 +27,18 @@
"minimumReleaseAge": null, "minimumReleaseAge": null,
// Max default value is 6, with this we give absolute priority to our own dependencies // Max default value is 6, with this we give absolute priority to our own dependencies
"prPriority": 7, "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", "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 name: Project Check Suite
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [konsist, lint, ktlint, detekt] 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: 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 - name: Download reports from previous jobs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- name: Prepare Danger - name: Annotate the lint, ktlint and detekt issues
if: always() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" script: |
env: // GitHub only surfaces a handful of annotations per level and per step, so cap what we emit
DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} // and let the job summary carry the full list.
# Fallback for forks const MAX_ANNOTATIONS = 20;
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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>posthog</w>
<w>rageshake</w> <w>rageshake</w>
<w>securebackup</w> <w>securebackup</w>
<w>showkase</w>
<w>snackbar</w> <w>snackbar</w>
<w>spdx</w> <w>spdx</w>
<w>swipeable</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 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 verbose = true
reporters { reporters {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) 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) reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
} }
val generatedPath = "${layout.buildDirectory.asFile.get()}/generated/" 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: 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). - 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 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 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 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. at the bottom of the list.
The developer options include feature flags, notification/push history, Element call The developer options include feature flags, notification/push history, Element call
customization, Rust SDK log levels, per-feature tracing toggles, Showkase to debug UI customization, Rust SDK log levels, per-feature tracing toggles, rageshake controls, app
components, rageshake controls, app crash controls, cache details/controls, persistent crash controls, cache details/controls, persistent storage maintenance tasks.
storage maintenance tasks.
Keywords: Developer settings, developer mode Keywords: Developer settings, developer mode
+1 -1
View File
@@ -44,7 +44,7 @@ The CI checks that:
2. The tests are passing 2. The tests are passing
3. The code quality is good (detekt, ktlint, lint) 3. The code quality is good (detekt, ktlint, lint)
4. The code is running and smoke tests are passing (maestro) 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 6. Files that must be added with git-lfs are added with git-lfs
## What is the CI reporting ## 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. * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
*/ */
fun interface ClientBuilderEnterpriseHook { interface ClientBuilderEnterpriseHook {
suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder /**
* 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) @ContributesBinding(AppScope::class)
class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { 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 // No modification
return clientBuilder 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.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomInfo 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.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.MessageSearch
import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
import io.element.android.libraries.matrix.api.search.MessageSearchResult import io.element.android.libraries.matrix.api.search.MessageSearchResult
@@ -94,7 +95,14 @@ class GlobalSearchPresenter(
AsyncData.Uninitialized 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 { launch {
currentMessageSearch.setQuery(queryState.text.toString()) currentMessageSearch.setQuery(queryState.text.toString())
.onFailure { Timber.e(it, "Could not set query for message search") } .onFailure { Timber.e(it, "Could not set query for message search") }
@@ -60,12 +60,16 @@ class RoomListSearchDataSource(
roomList.updateVisibleRange(visibleRange) roomList.updateVisibleRange(visibleRange)
} }
suspend fun setSearchQuery(searchQuery: String) = coroutineScope { suspend fun setSearchQuery(searchQuery: String, additionalFilters: RoomListFilter?) = coroutineScope {
val filter = if (searchQuery.isBlank()) { val filter = if (searchQuery.isBlank()) {
RoomListFilter.None RoomListFilter.None
} else {
if (additionalFilters != null) {
RoomListFilter.all(additionalFilters, RoomListFilter.NormalizedMatchRoomName(searchQuery))
} else { } else {
RoomListFilter.NormalizedMatchRoomName(searchQuery) RoomListFilter.NormalizedMatchRoomName(searchQuery)
} }
}
roomList.updateFilter(filter) roomList.updateFilter(filter)
} }
} }
@@ -39,7 +39,7 @@ class RoomListSearchPresenter(
val dataSource = remember { dataSourceFactory.create(coroutineScope) } val dataSource = remember { dataSourceFactory.create(coroutineScope) }
LaunchedEffect(searchQuery.text) { LaunchedEffect(searchQuery.text) {
dataSource.setSearchQuery(searchQuery.text.toString()) dataSource.setSearchQuery(searchQuery = searchQuery.text.toString(), additionalFilters = null)
} }
fun handleEvent(event: RoomListSearchEvent) { 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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.permalink.PermalinkParser 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.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -142,6 +145,9 @@ class GlobalSearchPresenterTest {
testScheduler.advanceUntilIdle() testScheduler.advanceUntilIdle()
roomList.summaries.emit(listOf(aRoomSummary())) 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 // Wait for the presenter to emit a loading state before the success state
consumeItemsUntilPredicate { it.results is AsyncData.Loading } 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.NodeInputs
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs 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.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
@@ -103,6 +104,7 @@ class MessagesNode(
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val eventContentValidationCache: EventContentValidationCache, private val eventContentValidationCache: EventContentValidationCache,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val dispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val focusedEventId: EventId?, val focusedEventId: EventId?,
@@ -111,7 +113,12 @@ class MessagesNode(
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val callback: Callback = callback() 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( private val presenter = presenterFactory.create(
navigator = this, navigator = this,
composerPresenter = messageComposerPresenterFactory.create(timelineController, this, threadRoot = null), 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.Presenter
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs 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.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -95,6 +96,7 @@ class ThreadedMessagesNode(
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val dispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val threadRootEventId: ThreadId, val threadRootEventId: ThreadId,
@@ -113,7 +115,12 @@ class ThreadedMessagesNode(
*/ */
private suspend fun createPresenter(): Presenter<MessagesState> { private suspend fun createPresenter(): Presenter<MessagesState> {
val threadedTimeline = room.createTimeline(CreateTimelineParams.Threaded(threadRootEventId = inputs.threadRootEventId)).getOrThrow() 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 this.timelineController = timelineController
return presenterFactory.create( return presenterFactory.create(
navigator = this, navigator = this,
@@ -12,7 +12,10 @@ import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import dev.zacsweers.metro.binding import dev.zacsweers.metro.binding
import io.element.android.features.messages.impl.timeline.di.LiveTimeline 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.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.EventId
import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.CreateTimelineParams 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,8 +49,10 @@ import java.util.Optional
class TimelineController( class TimelineController(
private val room: JoinedRoom, private val room: JoinedRoom,
@LiveTimeline private val liveTimeline: Timeline, @LiveTimeline private val liveTimeline: Timeline,
@RoomCoroutineScope private val roomCoroutineScope: CoroutineScope,
dispatchers: CoroutineDispatchers,
) : Closeable, TimelineProvider { ) : Closeable, TimelineProvider {
private val coroutineScope = CoroutineScope(SupervisorJob()) private val coroutineScope = roomCoroutineScope.childScope(dispatchers.computation, "TimelineController")
private val liveTimelineFlow = flowOf(liveTimeline) private val liveTimelineFlow = flowOf(liveTimeline)
private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty()) private val detachedTimelineFlow = MutableStateFlow<Optional<Timeline>>(Optional.empty())
@@ -1484,7 +1484,12 @@ class MessagesPresenterTest {
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
htmlConverterProvider = FakeHtmlConverterProvider(), htmlConverterProvider = FakeHtmlConverterProvider(),
buildMeta = aBuildMeta(), buildMeta = aBuildMeta(),
timelineController = TimelineController(joinedRoom, timeline), timelineController = TimelineController(
room = joinedRoom,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = coroutineDispatchers,
),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
analyticsService = analyticsService, analyticsService = analyticsService,
encryptionService = encryptionService, 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -307,7 +308,12 @@ class MessageComposerPresenterSlashCommandTest {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline), timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, 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.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.element.android.tests.testutils.waitForPredicate import io.element.android.tests.testutils.waitForPredicate
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -1707,7 +1708,12 @@ class MessageComposerPresenterTest : RobolectricTest() {
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
permalinkParser = permalinkParser, permalinkParser = permalinkParser,
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
timelineController = TimelineController(room, timeline), timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
draftService = draftService, draftService = draftService,
mentionSpanProvider = mentionSpanProvider, mentionSpanProvider = mentionSpanProvider,
pillificationHelper = textPillificationHelper, 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.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -35,7 +37,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@@ -74,7 +79,10 @@ class TimelineControllerTest {
} }
} }
) )
val sut = TimelineController(joinedRoom, liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@@ -102,7 +110,10 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline liveTimeline = liveTimeline
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
@@ -121,7 +132,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state).isEqualTo(liveTimeline) assertThat(state).isEqualTo(liveTimeline)
@@ -149,7 +163,10 @@ class TimelineControllerTest {
val joinedRoom = FakeJoinedRoom( val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline liveTimeline = liveTimeline
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
assertThat(sut.timelineItems().first()).hasSize(1) assertThat(sut.timelineItems().first()).hasSize(1)
} }
@@ -171,7 +188,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
sut.focusOnEvent(AN_EVENT_ID, null) sut.focusOnEvent(AN_EVENT_ID, null)
awaitItem().also { state -> awaitItem().also { state ->
@@ -196,7 +216,10 @@ class TimelineControllerTest {
liveTimeline = liveTimeline, liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) } createTimelineResult = { Result.success(detachedTimeline) }
) )
val sut = TimelineController(room = joinedRoom, liveTimeline = liveTimeline) val sut = createTimelineController(
room = joinedRoom,
liveTimeline = liveTimeline,
)
sut.activeTimelineFlow().test { sut.activeTimelineFlow().test {
awaitItem().also { state -> awaitItem().also { state ->
@@ -220,12 +243,14 @@ class TimelineControllerTest {
} }
} }
internal fun createTimelineController( internal fun TestScope.createTimelineController(
room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()), room: FakeJoinedRoom = FakeJoinedRoom(liveTimeline = FakeTimeline()),
liveTimeline: Timeline = FakeTimeline(name = "live"), liveTimeline: Timeline = FakeTimeline(name = "live"),
): TimelineController { ): TimelineController {
return TimelineController( return TimelineController(
room = room, 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() val focusedState = consumeItemsUntilPredicate { it.focusRequestState == FocusRequestState.Success(AN_EVENT_ID) }.last()
assertThat(focusedState.isLive).isFalse() assertThat(focusedState.isLive).isFalse()
initialState.eventSink.invoke(TimelineEvent.LoadMore(Timeline.PaginationDirection.FORWARDS)) 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() assert(paginateLambda).isNeverCalled()
assertThat(expectMostRecentItem().isLive).isFalse() assertThat(expectMostRecentItem().isLive).isFalse()
} }
@@ -1906,7 +1910,12 @@ class TimelinePresenterTest {
sendPollResponseAction = sendPollResponseAction, sendPollResponseAction = sendPollResponseAction,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
timelineItemIndexer = timelineItemIndexer, timelineItemIndexer = timelineItemIndexer,
timelineController = TimelineController(room, timeline), timelineController = TimelineController(
room = room,
liveTimeline = timeline,
roomCoroutineScope = backgroundScope,
dispatchers = testCoroutineDispatchers(),
),
resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter, resolveVerifiedUserSendFailurePresenter = resolveVerifiedUserSendFailurePresenter,
typingNotificationPresenter = { aTypingNotificationState() }, typingNotificationPresenter = { aTypingNotificationState() },
roomCallStatePresenter = { aStandByCallState() }, roomCallStatePresenter = { aStandByCallState() },
@@ -94,7 +94,6 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json) implementation(libs.serialization.json)
api(projects.features.preferences.api) api(projects.features.preferences.api)
implementation(libs.showkase)
implementation(platform(libs.network.okhttp.bom)) implementation(platform(libs.network.okhttp.bom))
implementation(libs.network.okhttp) implementation(libs.network.okhttp)
@@ -8,10 +8,8 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.airbnb.android.showkase.models.Showkase
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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.annotations.ContributesNode
import io.element.android.features.preferences.api.ExtraDeveloperOptionsRenderer import io.element.android.features.preferences.api.ExtraDeveloperOptionsRenderer
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.designsystem.showkase.getBrowserIntent
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@@ -47,17 +44,10 @@ class DeveloperSettingsNode(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
fun openShowkase() {
val intent = Showkase.getBrowserIntent(activity)
activity.startActivity(intent)
}
val state = presenter.present() val state = presenter.present()
DeveloperSettingsView( DeveloperSettingsView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onOpenShowkase = ::openShowkase,
onPushHistoryClick = callback::navigateToPushHistory, onPushHistoryClick = callback::navigateToPushHistory,
onBackClick = callback::onDone, onBackClick = callback::onDone,
extraOptions = { extraDeveloperOptionsRenderer.Render(Modifier) }, extraOptions = { extraDeveloperOptionsRenderer.Render(Modifier) },
@@ -44,7 +44,6 @@ import io.mhssn.colorpicker.ColorPickerType
@Composable @Composable
fun DeveloperSettingsView( fun DeveloperSettingsView(
state: DeveloperSettingsState, state: DeveloperSettingsState,
onOpenShowkase: () -> Unit,
onPushHistoryClick: () -> Unit, onPushHistoryClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -84,7 +83,6 @@ fun DeveloperSettingsView(
// Note: this is OK to hardcode strings in this debug screen. // Note: this is OK to hardcode strings in this debug screen.
AppDeveloperSettingsView( AppDeveloperSettingsView(
state = state.appDeveloperSettingsState, state = state.appDeveloperSettingsState,
onOpenShowkase = onOpenShowkase,
) )
SessionCategory(deviceId = state.deviceId) SessionCategory(deviceId = state.deviceId)
NotificationCategory( NotificationCategory(
@@ -255,7 +253,6 @@ internal fun DeveloperSettingsViewPreview(
) = ElementPreview { ) = ElementPreview {
DeveloperSettingsView( DeveloperSettingsView(
state = state, state = state,
onOpenShowkase = {},
onPushHistoryClick = {}, onPushHistoryClick = {},
onBackClick = {}, onBackClick = {},
) )
@@ -7,10 +7,8 @@
package io.element.android.features.preferences.impl.developer.appsettings package io.element.android.features.preferences.impl.developer.appsettings
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.airbnb.android.showkase.models.Showkase
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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.ExtraDeveloperOptionsRenderer
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.designsystem.showkase.getBrowserIntent
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@AssistedInject @AssistedInject
@@ -35,17 +32,10 @@ class AppDeveloperSettingsNode(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
fun openShowkase() {
val intent = Showkase.getBrowserIntent(activity)
activity.startActivity(intent)
}
val state = presenter.present() val state = presenter.present()
AppDeveloperSettingsPage( AppDeveloperSettingsPage(
state = state, state = state,
modifier = modifier, modifier = modifier,
onOpenShowkase = ::openShowkase,
onBackClick = callback::onDone, onBackClick = callback::onDone,
extraOptions = { extraDeveloperOptionsRenderer.Render(Modifier) }, extraOptions = { extraDeveloperOptionsRenderer.Render(Modifier) },
) )
@@ -21,7 +21,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable @Composable
fun AppDeveloperSettingsPage( fun AppDeveloperSettingsPage(
state: AppDeveloperSettingsState, state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
extraOptions: @Composable () -> Unit = {}, extraOptions: @Composable () -> Unit = {},
@@ -38,7 +37,6 @@ fun AppDeveloperSettingsPage(
) { ) {
AppDeveloperSettingsView( AppDeveloperSettingsView(
state = state, state = state,
onOpenShowkase = onOpenShowkase,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
extraOptions() extraOptions()
@@ -50,7 +48,6 @@ fun AppDeveloperSettingsPage(
internal fun AppDeveloperSettingsPagePreview() = ElementPreview { internal fun AppDeveloperSettingsPagePreview() = ElementPreview {
AppDeveloperSettingsPage( AppDeveloperSettingsPage(
state = anAppDeveloperSettingsState(), state = anAppDeveloperSettingsState(),
onOpenShowkase = {},
onBackClick = {}, onBackClick = {},
) )
} }
@@ -42,7 +42,6 @@ import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun AppDeveloperSettingsView( fun AppDeveloperSettingsView(
state: AppDeveloperSettingsState, state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@@ -82,14 +81,6 @@ fun AppDeveloperSettingsView(
) )
} }
} }
PreferenceCategory(title = "Showkase") {
ListItem(
content = {
Text("Open Showkase browser")
},
onClick = onOpenShowkase
)
}
RageshakePreferencesView( RageshakePreferencesView(
state = state.rageshakeState, state = state.rageshakeState,
) )
@@ -186,6 +177,5 @@ internal fun AppDeveloperSettingsViewPreview(
) = ElementPreview { ) = ElementPreview {
AppDeveloperSettingsView( AppDeveloperSettingsView(
state = state, state = state,
onOpenShowkase = {},
) )
} }
@@ -70,21 +70,6 @@ class DeveloperSettingsViewTest : RobolectricTest() {
eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules) 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") @Config(qualifiers = "h2400dp")
@Test @Test
fun `clicking on clear cache emits the expected event`() = runAndroidComposeUiTest { fun `clicking on clear cache emits the expected event`() = runAndroidComposeUiTest {
@@ -101,14 +86,12 @@ class DeveloperSettingsViewTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.setDeveloperSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setDeveloperSettingsView(
state: DeveloperSettingsState, state: DeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onPushHistoryClick: () -> Unit = EnsureNeverCalled(), onPushHistoryClick: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled() onBackClick: () -> Unit = EnsureNeverCalled()
) { ) {
setContent { setContent {
DeveloperSettingsView( DeveloperSettingsView(
state = state, state = state,
onOpenShowkase = onOpenShowkase,
onPushHistoryClick = onPushHistoryClick, onPushHistoryClick = onPushHistoryClick,
onBackClick = onBackClick, onBackClick = onBackClick,
) )
@@ -64,21 +64,6 @@ class AppDeveloperSettingsPageTest : RobolectricTest() {
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev")) 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") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on log level emits the expected event`() = runAndroidComposeUiTest { fun `clicking on log level emits the expected event`() = runAndroidComposeUiTest {
@@ -96,13 +81,11 @@ class AppDeveloperSettingsPageTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.setAppDeveloperSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setAppDeveloperSettingsView(
state: AppDeveloperSettingsState, state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
AppDeveloperSettingsPage( AppDeveloperSettingsPage(
state = state, state = state,
onOpenShowkase = onOpenShowkase,
onBackClick = onBackClick, onBackClick = onBackClick,
) )
} }
+3 -7
View File
@@ -39,9 +39,7 @@ datetime = "0.8.0"
serialization_json = "1.11.0" serialization_json = "1.11.0"
#other #other
coil = "3.6.1" coil = "3.6.2"
# 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. # 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. # When upgrading this version, check state restoration still works fine.
appyx = "1.7.1" 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 # 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 # 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. # 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 # Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } 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" } datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization_json" } 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" 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" jsoup = "org.jsoup:jsoup:1.23.2"
appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" }
molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0" molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0"
@@ -230,7 +226,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0"
# Analytics # Analytics
posthog = "com.posthog:posthog-android:3.60.7" 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 # main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0"
-1
View File
@@ -22,7 +22,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.showkase)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(libs.test.roborazzi) testImplementation(libs.test.roborazzi)
testImplementation(libs.test.roborazzi.compose) 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.text.style.LineHeightStyle
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.android.showkase.annotation.ShowkaseTypography
import io.element.android.compound.tokens.generated.TypographyTokens import io.element.android.compound.tokens.generated.TypographyTokens
// 32px (Material) vs 34px, it's the closest one // 32px (Material) vs 34px, it's the closest one
@ShowkaseTypography(name = "M3 Headline Large", group = "Compound")
internal val compoundHeadingXlRegular = TypographyTokens.fontHeadingXlRegular internal val compoundHeadingXlRegular = TypographyTokens.fontHeadingXlRegular
// both are 28px // both are 28px
@ShowkaseTypography(name = "M3 Headline Medium", group = "Compound")
internal val compoundHeadingLgRegular = TypographyTokens.fontHeadingLgRegular 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 // 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( internal val defaultHeadlineSmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -40,39 +36,27 @@ internal val defaultHeadlineSmall = TextStyle(
) )
// 22px (Material) vs 20px, it's the closest one // 22px (Material) vs 20px, it's the closest one
@ShowkaseTypography(name = "M3 Title Large", group = "Compound")
internal val compoundHeadingMdRegular = TypographyTokens.fontHeadingMdRegular internal val compoundHeadingMdRegular = TypographyTokens.fontHeadingMdRegular
// 16px both // 16px both
@ShowkaseTypography(name = "M3 Title Medium", group = "Compound")
internal val compoundBodyLgMedium = TypographyTokens.fontBodyLgMedium internal val compoundBodyLgMedium = TypographyTokens.fontBodyLgMedium
// 14px both // 14px both
@ShowkaseTypography(name = "M3 Title Small", group = "Compound")
internal val compoundBodyMdMedium = TypographyTokens.fontBodyMdMedium internal val compoundBodyMdMedium = TypographyTokens.fontBodyMdMedium
// 16px both // 16px both
@ShowkaseTypography(name = "M3 Body Large", group = "Compound")
internal val compoundBodyLgRegular = TypographyTokens.fontBodyLgRegular internal val compoundBodyLgRegular = TypographyTokens.fontBodyLgRegular
// 14px both // 14px both
@ShowkaseTypography(name = "M3 Body Medium", group = "Compound")
internal val compoundBodyMdRegular = TypographyTokens.fontBodyMdRegular internal val compoundBodyMdRegular = TypographyTokens.fontBodyMdRegular
// 12px both // 12px both
@ShowkaseTypography(name = "M3 Body Small", group = "Compound")
internal val compoundBodySmRegular = TypographyTokens.fontBodySmRegular 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 // 12px both
@ShowkaseTypography(name = "M3 Label Medium", group = "Compound")
internal val compoundBodySmMedium = TypographyTokens.fontBodySmMedium internal val compoundBodySmMedium = TypographyTokens.fontBodySmMedium
// 11px both // 11px both
@ShowkaseTypography(name = "M3 Label Small", group = "Compound")
internal val compoundBodyXsMedium = TypographyTokens.fontBodyXsMedium internal val compoundBodyXsMedium = TypographyTokens.fontBodyXsMedium
internal val compoundTypography = Typography( internal val compoundTypography = Typography(
@@ -88,7 +72,7 @@ internal val compoundTypography = Typography(
bodyLarge = compoundBodyLgRegular, bodyLarge = compoundBodyLgRegular,
bodyMedium = compoundBodyMdRegular, bodyMedium = compoundBodyMdRegular,
bodySmall = compoundBodySmRegular, bodySmall = compoundBodySmRegular,
labelLarge = compoundBodyMdMedium_LabelLarge, labelLarge = compoundBodyMdMedium,
labelMedium = compoundBodySmMedium, labelMedium = compoundBodySmMedium,
labelSmall = compoundBodyXsMedium, labelSmall = compoundBodyXsMedium,
) )
-10
View File
@@ -10,7 +10,6 @@ import extension.testCommonDependencies
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
@@ -20,12 +19,6 @@ android {
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
buildTypes {
getByName("release") {
consumerProguardFiles("consumer-rules.keep")
}
}
} }
dependencies { dependencies {
@@ -43,8 +36,5 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
ksp(libs.showkase.processor)
implementation(libs.showkase)
testCommonDependencies(libs) 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 import androidx.compose.ui.unit.Density
/** /**
* Showkase does not take into account the `fontScale` parameter of the Preview annotation, so alter the * Render the content at the given font scale, by altering the `LocalDensity` in the
* LocalDensity in the CompositionLocalProvider. * CompositionLocalProvider. Useful to preview a Composable at several font scales.
*/ */
@Composable @Composable
fun WithFontScale(fontScale: Float, content: @Composable () -> Unit) { 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 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. * 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 io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.RequestConfig
@@ -100,7 +99,12 @@ class RustMatrixClientFactory(
) )
.homeserverUrl(sessionData.homeserverUrl) .homeserverUrl(sessionData.homeserverUrl)
.enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) .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() } .use { it.build() }
client.setMediaRetentionPolicy( client.setMediaRetentionPolicy(
@@ -118,14 +122,6 @@ class RustMatrixClientFactory(
client.restoreSession(sessionData.toSession()) client.restoreSession(sessionData.toSession())
create(client, sessionData, isMessageSearchAvailable)
}
suspend fun create(
client: Client,
sessionData: SessionData,
isMessageSearchAvailable: Boolean,
): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens() val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
client.setUtdDelegate(UtdTracker(analyticsService)) client.setUtdDelegate(UtdTracker(analyticsService))
@@ -135,7 +131,7 @@ class RustMatrixClientFactory(
.withOfflineMode() .withOfflineMode()
.finish() .finish()
return RustMatrixClient( RustMatrixClient(
sessionPaths = sessionData.getSessionPaths(), sessionPaths = sessionData.getSessionPaths(),
innerClient = client, innerClient = client,
sessionStore = sessionStore, 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 // 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 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.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.sessionstorage.api.LoginType 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 io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -101,17 +102,7 @@ class RustMatrixAuthenticationService(
runCatchingExceptions { runCatchingExceptions {
val sessionData = sessionStore.getSession(sessionId.value) val sessionData = sessionStore.getSession(sessionId.value)
if (sessionData != null) { if (sessionData != null) {
if (sessionData.isTokenValid) { restoreSession(sessionData)
// 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()
}
} else { } else {
throw SessionRestorationException.MissingSession(sessionId) 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 { private fun getDatabaseKey(): ClientSecret {
Timber.d("New sessions will be encrypted with a raw key") Timber.d("New sessions will be encrypted with a raw key")
return secretGenerator.generateKey() return secretGenerator.generateKey()
@@ -137,7 +141,7 @@ class RustMatrixAuthenticationService(
client.homeserverLoginDetails().map() client.homeserverLoginDetails().map()
}.onFailure { }.onFailure {
clear(destroyClient = true) clear()
}.mapFailure { failure -> }.mapFailure { failure ->
Timber.e(failure, "Failed to set homeserver to $homeserver") Timber.e(failure, "Failed to set homeserver to $homeserver")
failure.mapAuthenticationException() failure.mapAuthenticationException()
@@ -165,18 +169,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(), passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) finalizeClientCreation(sessionData)
// 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)
}.mapFailure { failure -> }.mapFailure { failure ->
Timber.e(failure, "Failed to login") Timber.e(failure, "Failed to login")
failure.mapAuthenticationException() failure.mapAuthenticationException()
@@ -305,20 +298,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(), passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) finalizeClientCreation(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(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure -> }.mapFailure { failure ->
Timber.e(failure, "Failed to login with OAuth") Timber.e(failure, "Failed to login with OAuth")
failure.mapAuthenticationException() failure.mapAuthenticationException()
@@ -357,6 +337,7 @@ class RustMatrixAuthenticationService(
sessionPaths = emptySessionPaths, sessionPaths = emptySessionPaths,
qrCodeData = sdkQrCodeLoginData, qrCodeData = sdkQrCodeLoginData,
) )
currentClient = client
client.newLoginWithQrCodeHandler( client.newLoginWithQrCodeHandler(
oauthConfiguration = oAuthConfiguration, oauthConfiguration = oAuthConfiguration,
).use { ).use {
@@ -374,18 +355,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(), passphrase = pendingKey.formattedAsString(),
sessionPaths = emptySessionPaths, sessionPaths = emptySessionPaths,
) )
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) finalizeClientCreation(sessionData)
// 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)
}.mapFailure { }.mapFailure {
when (it) { when (it) {
is QrCodeDecodeException -> QrErrorMapper.map(it) is QrCodeDecodeException -> QrErrorMapper.map(it)
@@ -393,6 +363,8 @@ class RustMatrixAuthenticationService(
else -> it else -> it
} }
}.onFailure { throwable -> }.onFailure { throwable ->
// A QR code login always builds its own client, so it can be disposed of on failure.
clear()
if (throwable is CancellationException) { if (throwable is CancellationException) {
throw throwable 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( private suspend fun makeClient(
sessionPaths: SessionPaths, sessionPaths: SessionPaths,
config: suspend ClientBuilder.() -> ClientBuilder, config: suspend ClientBuilder.() -> ClientBuilder,
@@ -449,10 +442,8 @@ class RustMatrixAuthenticationService(
.build() .build()
} }
private fun clear(destroyClient: Boolean) { private fun clear() {
if (destroyClient) {
currentClient?.close() currentClient?.close()
}
currentClient = null 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.Favourite
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Identifiers import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Identifiers
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.Invite 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.NonLeft
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.NonSpace
import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind.None
@@ -73,6 +74,7 @@ internal object RoomListFilterMapper {
is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName( is RoomListFilter.NormalizedMatchRoomName -> NormalizedMatchRoomName(
pattern = filter.pattern pattern = filter.pattern
) )
RoomListFilter.Joined -> Joined
RoomListFilter.Invite -> Invite RoomListFilter.Invite -> Invite
} }
} }
@@ -9,11 +9,13 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat 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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId 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.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory 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.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@@ -51,6 +53,7 @@ fun TestScope.createRustMatrixClientFactory(
), ),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
) = RustMatrixClientFactory( ) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory, cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
@@ -63,7 +66,7 @@ fun TestScope.createRustMatrixClientFactory(
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
timelineEventFilterFactory = FakeTimelineEventFilterFactory(), timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
clientBuilderProvider = clientBuilderProvider, clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
clientBuilderEnterpriseHook = { builder, _ -> builder }, clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
) )
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat 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.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
@@ -77,6 +78,6 @@ class RustTemporaryMatrixClientFactoryTest {
clientBuilderProvider = clientBuilderProvider, clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler, 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.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider 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.createRustMatrixClientFactory
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient 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.FakeFfiClientBuilder
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails 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.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.auth.FakeOAuthRedirectUrlProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore 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.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test 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 import java.io.File
class RustMatrixAuthenticationServiceTest { class RustMatrixAuthenticationServiceTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test @Test
fun `setHomeserver is successful`() = runTest { fun `setHomeserver is successful`() = runTest {
val sut = createRustMatrixAuthenticationService( val sut = createRustMatrixAuthenticationService(
@@ -74,20 +93,183 @@ class RustMatrixAuthenticationServiceTest {
closeResult.assertions().isCalledOnce() 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( private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
): RustMatrixAuthenticationService { ): RustMatrixAuthenticationService {
val baseDirectory = File("/base")
val cacheDirectory = File("/cache")
val rustMatrixClientFactory = createRustMatrixClientFactory( val rustMatrixClientFactory = createRustMatrixClientFactory(
cacheDirectory = cacheDirectory, cacheDirectory = File("/cache"),
sessionStore = sessionStore, sessionStore = sessionStore,
clientBuilderProvider = clientBuilderProvider, clientBuilderProvider = clientBuilderProvider,
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
) )
return RustMatrixAuthenticationService( return RustMatrixAuthenticationService(
sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory), sessionPathsFactory = sessionPathsFactory,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore, sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory, rustMatrixClientFactory = rustMatrixClientFactory,
@@ -25,10 +25,12 @@ import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.NotificationSettings import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.OAuthConfiguration
import org.matrix.rustcomponents.sdk.ProfileListener import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.PusherIdentifiers import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind import org.matrix.rustcomponents.sdk.PusherKind
@@ -57,6 +59,8 @@ class FakeFfiClient(
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val getProfileResult: (String) -> UserProfile = { aRustUserProfile() }, private val getProfileResult: (String) -> UserProfile = { aRustUserProfile() },
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val loginResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val newLoginWithQrCodeHandlerResult: () -> LoginWithQrCodeHandler = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
@@ -122,6 +126,14 @@ class FakeFfiClient(
return homeserverLoginDetailsResult() 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 setMediaRetentionPolicy(policy: MediaRetentionPolicy) {}
override suspend fun getStoreSizes(): StoreSizes { 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( class FakeFfiQrCodeData(
private val serverNameResult: () -> String? = { lambdaError() }, private val serverNameResult: () -> String? = { lambdaError() },
private val baseUrlResult: () -> String? = { lambdaError() },
private val toBytesResult: () -> ByteArray = { lambdaError() }, private val toBytesResult: () -> ByteArray = { lambdaError() },
) : QrCodeData(NoHandle) { ) : QrCodeData(NoHandle) {
override fun serverName(): String? { override fun serverName(): String? {
return serverNameResult() return serverNameResult()
} }
override fun baseUrl(): String? {
return baseUrlResult()
}
override fun toBytes(): ByteArray { override fun toBytes(): ByteArray {
return toBytesResult() 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]. * Release number in the month. Value must be in [0,99].
* Do not update this value. it is updated by the release script. * Do not update this value. it is updated by the release script.
*/ */
private const val versionReleaseNumber = 1 private const val versionReleaseNumber = 2
object Versions { object Versions {
/** /**
@@ -107,13 +107,8 @@ fun Project.setupKover() {
// Exclude generated classes. // Exclude generated classes.
"*_Module", "*_Module",
"*_AssistedFactory", "*_AssistedFactory",
"com.airbnb.android.showkase*",
"io.element.android.libraries.designsystem.showkase.*",
"*ComposableSingletons$*", "*ComposableSingletons$*",
"*BuildConfig", "*BuildConfig",
// Generated by Showkase
"*Ioelementandroid*PreviewKt$*",
"*Ioelementandroid*PreviewKt",
// Other // Other
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro) // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
"*Node", "*Node",
+12
View File
@@ -25,7 +25,19 @@ dependencyResolutionManagement {
includeModule("com.github.philburk", "jsyn") includeModule("com.github.philburk", "jsyn")
} }
} }
// Check for official Android-related packages only in Google's maven repo
exclusiveContent {
forRepository {
google() google()
}
filter {
includeGroupByRegex("android\\..*")
includeGroupByRegex("androidx\\..*")
includeGroupByRegex("com\\.android\\.tools.*")
includeGroupByRegex("com\\.google\\.firebase.*")
includeGroupByRegex("com\\.google\\.android.*")
}
}
mavenCentral() mavenCentral()
maven { maven {
url = uri("https://repo1.maven.org/maven2/") url = uri("https://repo1.maven.org/maven2/")
@@ -105,6 +105,7 @@ class KonsistClassNameTest {
"FakeListenableFuture", "FakeListenableFuture",
"FakeMediaStoreContentProvider", "FakeMediaStoreContentProvider",
"FakeParentNode", "FakeParentNode",
"FakeSequentialClientBuilderProvider",
) )
.assertTrue { .assertTrue {
val interfaceName = it.name 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 // 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 // `testOptions { unitTests.isIncludeAndroidResources = true }` in the app build.gradle.kts file
// implementation(projects.app) // 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' sdkVersion:'24'
targetSdkVersion:'37' targetSdkVersion:'37'
uses-permission: name='android.permission.POST_NOTIFICATIONS' uses-permission: name='android.permission.POST_NOTIFICATIONS'