Merge branch 'release/26.08.1'

This commit is contained in:
Benoit Marty
2026-08-12 12:34:54 +02:00
1390 changed files with 8998 additions and 4782 deletions
+2 -2
View File
@@ -51,12 +51,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs
+2 -2
View File
@@ -56,12 +56,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
+3 -3
View File
@@ -44,13 +44,13 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
@@ -98,7 +98,7 @@ jobs:
run: curl -fsSL "https://get.maestro.mobile.dev" | bash
- name: Run Maestro tests in emulator
id: maestro_test
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
continue-on-error: true
env:
MAESTRO_USERNAME: maestroelement
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
+4 -4
View File
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: false
@@ -80,12 +80,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
+13 -13
View File
@@ -69,12 +69,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -108,12 +108,12 @@ jobs:
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
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests
@@ -149,12 +149,12 @@ jobs:
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
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests
@@ -183,12 +183,12 @@ jobs:
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
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
@@ -228,12 +228,12 @@ jobs:
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
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
@@ -269,12 +269,12 @@ jobs:
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
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
upload_reports:
name: Project Check Suite
+2 -2
View File
@@ -54,13 +54,13 @@ jobs:
with:
persist-credentials: false
- name: ☕️ Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
+6 -6
View File
@@ -38,12 +38,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -82,12 +82,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create Enterprise app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -126,12 +126,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+2 -2
View File
@@ -45,12 +45,12 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
only-labels: "X-Needs-Info"
days-before-issue-stale: 30
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
with:
persist-credentials: false
- name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+12 -6
View File
@@ -12,7 +12,7 @@ permissions: {}
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
jobs:
tests:
@@ -63,17 +63,23 @@ jobs:
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
- name: ☕️ Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: ⚙️ Check coverage for debug variant (includes unit & screenshot tests)
run: ./gradlew testDebugUnitTest :tests:uitests:verifyPaparazziDebug :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Run unit tests
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Run screenshot tests
run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: ⚙️ Generate kover coverage report
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: 🚫 Upload kover failed coverage reports
if: failure()
@@ -81,7 +87,7 @@ jobs:
with:
name: kover-error-report
path: |
app/build/reports/kover
build/reports/kover
- name: ✅ Upload kover report (disabled)
if: always()
+83
View File
@@ -1,3 +1,86 @@
Changes in Element X v26.08.0
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.0 -->
## What's Changed
### ✨ Features
* feat(call): Active call timeline rendering by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/7051
### 🙌 Improvements
* Announce expanded/collapsed state on the timeline group header by @unclejay80 in https://github.com/element-hq/element-x-android/pull/7226
* Allow adding extra elements to the developer settings from external modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7230
* Update keyboard visibility to track hasFocus by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7284
### 🐛 Bugfixes
* Live location sharing timeout by @ganfra in https://github.com/element-hq/element-x-android/pull/7184
* Fix logging via QR code when using MDM-configured homeserver by @mxandreas in https://github.com/element-hq/element-x-android/pull/7228
* Fix #7181: Remove back arrow from device verified screen and use 'Done' text by @thirumani-vihaan in https://github.com/element-hq/element-x-android/pull/7222
* Fix padding for captions of media in 'not found' state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7247
* Fix banned mime type errors in `DefaultContentScannerService` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7255
* Fix `ContentErrorView` not calculating its text size synchronously by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7281
* Fix media viewer for medias not related to events displaying invalid state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7280
* Try making `HeaderFooterView` more robust by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7267
* Fix insets in several screens by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7285
### ⚠️ API Changes
* Add a password strength estimator wrapping the SDK zxcvbn API by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7186
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7277
### 🧱 Build
* CI: fix 'Update Gradle Wrapper' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7238
* Update Gradle Wrapper from 9.5.1 to 9.6.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7240
* Localazy: improve search, parallelize checks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7261
* Run `Konsist` tests sequentially by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7293
### 📄 Documentation
* Group Element Pro changes into a dedicated release notes section by @manuroe in https://github.com/element-hq/element-x-android/pull/7236
### 🚧 In development 🚧
* Plug emoji picker for custom user status by @ganfra in https://github.com/element-hq/element-x-android/pull/7257
* WIP : display user status by @ganfra in https://github.com/element-hq/element-x-android/pull/7232
### Dependency upgrades
* Update roborazzi to v1.67.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7188
* Update dependency io.sentry:sentry-android to v8.48.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7217
* Update dependency com.google.crypto.tink:tink-android to v1.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7229
* Update kotlin to v2.3.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7234
* Update dependency net.zetetic:sqlcipher-android to v4.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7218
* Update dependency com.google.firebase:firebase-bom to v34.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7237
* Update dependencyAnalysis to v3.16.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7212
* Update actions/setup-java action to v5.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7214
* Update dependency io.element.android:element-call-embedded to v0.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7161
* Update kotlin to v2.4.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7244
* Update roborazzi to v1.68.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7245
* Update metro to v1.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7243
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.22 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7258
* Update zizmorcore/zizmor-action action to v0.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7256
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7262
* Update dependency io.sentry:sentry-android to v8.49.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7263
* Update dependencyAnalysis to v3.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7265
* Update actions/setup-java action to v5.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7268
* Update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7273
* Update actions/setup-python action to v7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7278
* Update roborazzi to v1.70.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7274
* Update actions/checkout action to v7.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7282
* Import Compound tokens from `v10.2.3` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7272
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7287
* Update dependency org.maplibre.gl:android-sdk to v13.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7290
### 💼 Element Pro
* Content scanner (Element Pro): scan timeline events for unsafe content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7159
* Add content scanner support to media gallery and viewer (Element Pro) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7233
* Enforce HS disabling E2EE in DM creation too (Element Pro) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7292
* Allow adding a path suffix to the OAuth client URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7286
### Others
* [A11y] Improve rendering on focused UI items by @bmarty in https://github.com/element-hq/element-x-android/pull/7182
* Add a button role to clickable icons and text on the login/onboarding screens by @unclejay80 in https://github.com/element-hq/element-x-android/pull/7225
* Expose formatting toggle state to TalkBack by @unclejay80 in https://github.com/element-hq/element-x-android/pull/7227
* Add warning to the 'Scan QR code' screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7264
* Refactor SearchBarResultState.Initial/NoResultsFound to data objects by @ganfra in https://github.com/element-hq/element-x-android/pull/7266
* Remove no longer temporary strings by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7260
* Use the right insets in the home screen UI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7271
* Make settings section separators visible in black theme by @mvanhorn in https://github.com/element-hq/element-x-android/pull/7039
## New Contributors
* @thirumani-vihaan made their first contribution in https://github.com/element-hq/element-x-android/pull/7222
* @mvanhorn made their first contribution in https://github.com/element-hq/element-x-android/pull/7039
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.07.1...v26.08.0
Changes in Element X v26.07.1
=============================
+1
View File
@@ -60,6 +60,7 @@ dependencies {
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -65,6 +67,7 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -120,6 +123,16 @@ class LoggedInPresenter(
}.launchIn(this)
}
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) {
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -48,6 +51,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
}
}
@Test
fun `present - does not enable automatic call status when server does not support it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(false)
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledOnce()
.with(value(false))
}
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
),
buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta,
networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
)
}
+6 -2
View File
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
- Click on OK
4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
imported template and will not pick up changes made to `FeatureModule.json` on its own
- Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json`
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
`PreferencesRoot` in the `features/preferences/impl/root` package.
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -39,7 +39,7 @@ fun AnalyticsPreferencesView(
)
Column(modifier) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_analytics_settings_share_data))
},
supportingContent = {
@@ -312,7 +312,7 @@ private fun RoomJoinRuleOptions(
backgroundTint = Color.Transparent,
)
},
headlineContent = {
content = {
val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
@@ -58,7 +58,7 @@ internal fun SelectParentSpaceOptions(
modifier = modifier
) {
ListItem(
headlineContent = {
content = {
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
@@ -126,7 +126,7 @@ private fun SelectParentSpaceBottomSheet(
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
maxLines = 1
@@ -141,7 +141,7 @@ private fun SelectParentSpaceBottomSheet(
for (space in spaces) {
item {
ListItem(
headlineContent = {
content = {
Text(
text = space.displayName,
maxLines = 1
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
<string name="screen_deactivate_account_title">"停用帳號"</string>
<string name="screen_deactivate_account_title">"刪除帳號"</string>
</resources>
@@ -10,6 +10,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
@@ -17,7 +18,7 @@ import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
@@ -47,6 +48,11 @@ interface EnterpriseService {
*/
fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
@@ -0,0 +1,17 @@
/*
* 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.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -0,0 +1,23 @@
/*
* 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.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
}
@@ -0,0 +1,30 @@
/*
* 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.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -5,10 +5,12 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
package io.element.android.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
@@ -16,5 +18,12 @@ import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
tweakMasUrlResult(url, homeserver)
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver, urlContentFetcher)
}
override fun defaultHomeserverList(): List<String> {
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult()
}
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
}
@@ -0,0 +1,21 @@
/*
* 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.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -0,0 +1,17 @@
/*
* 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.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -14,12 +14,15 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -204,6 +207,9 @@ private fun HomeScaffold(
floatingActionButton = {
val coroutineScope = rememberCoroutineScope()
HomeBottomBar(
// The Scaffold uses top-only insets so the scrollable content can go edge-to-edge behind the
// navigation bar, so the floating toolbar has to apply the bottom inset itself to avoid overlapping it.
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
currentHomeNavigationBarItem = state.currentHomeNavigationBarItem,
onItemClick = { item ->
// scroll to top if selecting the same item
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
},
// We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton)
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp),
)
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier,
) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton(
modifier = modifier.then(testTag),
onClick = onClick,
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) {
Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
if (selectedFilters.any { it in filter.incompatibleFilters }) return
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
@@ -94,7 +94,7 @@ private fun RoomListModalBottomSheetContent(
.verticalScroll(rememberScrollState())
) {
ListItem(
headlineContent = {
content = {
Text(
text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
@@ -104,7 +104,7 @@ private fun RoomListModalBottomSheetContent(
)
if (contextMenu.hasNewContent) {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
@@ -117,7 +117,7 @@ private fun RoomListModalBottomSheetContent(
)
} else {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
style = MaterialTheme.typography.bodyLarge,
@@ -135,7 +135,7 @@ private fun RoomListModalBottomSheetContent(
CommonStrings.common_favourite to CompoundIcons.Favourite()
}
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = textResId),
style = MaterialTheme.typography.bodyLarge,
@@ -154,7 +154,7 @@ private fun RoomListModalBottomSheetContent(
},
)
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = CommonStrings.common_settings),
style = MaterialTheme.typography.bodyLarge,
@@ -169,7 +169,7 @@ private fun RoomListModalBottomSheetContent(
)
if (canReportRoom) {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_report_room))
},
modifier = Modifier.clickable { onReportRoomClick() },
@@ -182,7 +182,7 @@ private fun RoomListModalBottomSheetContent(
)
}
ListItem(
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_leave_room))
},
modifier = Modifier.clickable { onLeaveRoomClick() },
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Favourites, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.People, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest {
@@ -81,7 +81,7 @@ fun DeclineAndBlockView(
) {
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
content = {
Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title))
},
supportingContent = {
@@ -96,7 +96,7 @@ fun DeclineAndBlockView(
Spacer(modifier = Modifier.height(24.dp))
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_report_room))
},
supportingContent = {
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"已是成員"</string>
<string name="screen_invite_users_already_invited">"已邀請"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
</resources>
@@ -62,7 +62,7 @@ fun DependenciesDetailsView(
}
}
ListItem(
headlineContent = {
content = {
ClickableLinkText(
text = text,
interactionSource = remember { MutableInteractionSource() },
@@ -100,7 +100,7 @@ fun DependencyLicensesListView(
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
content = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null
private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null
fun createNewHandler() {
private fun resetJobs() {
currentJob?.cancel()
currentJob = null
timerJob?.cancel()
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
}
fun reset() {
currentJob?.cancel()
currentJob = null
resetJobs()
sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
}
}
fun onScannedCode(data: ByteArray) {
currentJob?.cancel()
currentJob = null
resetJobs()
val currentHandler = handler
if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
@Suppress("AssignedValueIsNeverRead")
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
},
onResume = ::onResume,
onDestroy = {
linkMobileHandlerJob?.cancel()
linkDesktopHandlerJob?.cancel()
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
.launchIn(sessionCoroutineScope)
}
private fun onResume() = sessionCoroutineScope.launch {
// Application is resumed, if the step is waiting for auth, send the confirmation
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
it.continuationMessageSender.confirm()
}
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
it.continuationMessageSender.confirm()
}
}
private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen
val error = when (errorType) {
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
/**
* Max number of QrCode rotation.
* When QrCode is rotated this number of time, a TimeOut error is emitted.
* Set to 0 to disable QrCode rotation.
*/
private const val MAX_QR_CODE_ROTATION = 0
@ContributesNode(SessionScope::class)
@AssistedInject
class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data,
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
)
@Composable
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
@Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> {
@AssistedFactory
interface Factory {
fun create(initialData: String): ShowQrCodePresenter
fun create(
initialData: String,
maxQrCodeRotation: Int,
): ShowQrCodePresenter
}
private var loadingJob: Job? = null
@Composable
override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
delay(1000)
delay(1.seconds)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state
}
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
}
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
@@ -91,10 +91,36 @@ class ShowQrCodePresenterTest {
}
}
@Test
fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
maxQrCodeRotation = 0,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
expectNoEvents()
}
}
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter(
initialData = "DATA",
maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler,
)
}
@@ -29,14 +29,11 @@ internal class MapTilerTileServerStyleUriBuilder(
customMapStyleUrl: String?,
darkMode: Boolean,
): String {
return buildString {
if (customMapStyleUrl.isNullOrBlank()) {
val mapId = if (darkMode) darkMapId else lightMapId
append("$baseUrl/$mapId/style.json")
} else {
append(customMapStyleUrl)
}
append("?key=$apiKey")
return if (customMapStyleUrl.isNullOrBlank()) {
val mapId = if (darkMode) darkMapId else lightMapId
"$baseUrl/$mapId/style.json?key=$apiKey"
} else {
customMapStyleUrl
}
}
}
@@ -46,7 +46,7 @@ class MapTilerTileServerStyleUriBuilderTest {
customMapStyleUrl = "https://custom.url/style.json",
darkMode = false,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
).isEqualTo("https://custom.url/style.json")
}
@Test
@@ -56,6 +56,36 @@ class MapTilerTileServerStyleUriBuilderTest {
customMapStyleUrl = "https://custom.url/style.json",
darkMode = true,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
).isEqualTo("https://custom.url/style.json")
}
@Test
fun `custom map uri with its own api key is not altered`() {
assertThat(
builder.build(
customMapStyleUrl = "https://api.maptiler.com/maps/streets-v2/style.json?key=anOperatorKey",
darkMode = false,
)
).isEqualTo("https://api.maptiler.com/maps/streets-v2/style.json?key=anOperatorKey")
}
@Test
fun `custom map uri with an existing query is not altered`() {
assertThat(
builder.build(
customMapStyleUrl = "https://self.hosted/style.json?foo=bar",
darkMode = true,
)
).isEqualTo("https://self.hosted/style.json?foo=bar")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
assertThat(
builder.build(
customMapStyleUrl = " ",
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
}
}
@@ -261,7 +261,7 @@ private fun ShareCurrentLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.screen_share_my_location_action))
},
onClick = onClick,
@@ -276,7 +276,7 @@ private fun SharePinLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
onClick = onClick,
@@ -291,7 +291,7 @@ private fun ShareLiveLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.action_share_live_location))
},
onClick = onClick,
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
</resources>
@@ -59,7 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
override suspend fun setup() {
if (cryptoObject != null) return
Timber.d("Setup biometric authentication")
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
if (this == null) return false
return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray())
}.isSuccess
}.onFailure {
Timber.e(it, "Failed to validate cipher")
}
.isSuccess
}
}
@@ -38,7 +38,7 @@ fun LockScreenSettingsView(
) {
PreferenceCategory(showTopDivider = false) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_app_lock_settings_change_pin))
},
onClick = onChangePinClick,
@@ -46,7 +46,7 @@ fun LockScreenSettingsView(
PreferenceDivider()
if (state.showRemovePinOption) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_app_lock_settings_remove_pin))
},
style = ListItemStyle.Destructive,
@@ -13,13 +13,15 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl(
private val enterpriseService: EnterpriseService,
private val wellknownRetriever: WellknownRetriever,
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider(
@@ -38,14 +40,21 @@ class DefaultAccountProviderAccessControl(
) {
if (enterpriseService.isEnterpriseBuild.not()) {
// Ensure that Element Pro is not required for this account provider
val wellKnown = wellknownRetriever.getElementWellKnown(
baseUrl = accountProviderUrl.ensureProtocol(),
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
temporaryMatrixClient.use {
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
val wellKnown = wellknownRetriever.getElementWellKnown(
host = accountProviderUrl,
// It's expected this is hardcoded to the well-known endpoint
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
}
}
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
@@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
@@ -34,8 +36,13 @@ class HomeserverResolver(
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim()
if (trimmedUserInput.length < 4) return@flow
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
val searchTerm = when {
MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
trimmedUserInput.startsWith("@") -> return@flow
else -> trimmedUserInput
}
if (searchTerm.length < 4) return@flow
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel
@@ -13,6 +13,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import timber.log.Timber
@@ -22,24 +24,32 @@ interface WebClientUrlForAuthenticationRetriever {
@ContributesBinding(AppScope::class)
class DefaultWebClientUrlForAuthenticationRetriever(
private val wellknownRetriever: WellknownRetriever,
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : WebClientUrlForAuthenticationRetriever {
override suspend fun retrieve(homeServerUrl: String): String {
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
Timber.w("Temporary account creation flow is only supported on matrix.org")
throw AccountCreationNotSupported()
}
val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull()
?: throw AccountCreationNotSupported()
val registrationHelperUrl = wellknown.registrationHelperUrl
return if (registrationHelperUrl != null) {
registrationHelperUrl.toUri()
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()
.toString()
} else {
throw AccountCreationNotSupported()
val temporaryMatrixClient = temporaryMatrixClientFactory.create(homeServerUrl).getOrThrow()
return temporaryMatrixClient.use {
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
val wellknown = wellknownRetriever.getElementWellKnown(
host = homeServerUrl,
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT
).dataOrNull() ?: throw AccountCreationNotSupported()
val registrationHelperUrl = wellknown.registrationHelperUrl
if (registrationHelperUrl != null) {
registrationHelperUrl.toUri()
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()
.toString()
} else {
throw AccountCreationNotSupported()
}
}
}
}
@@ -37,7 +37,14 @@
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Element Classic öffnen"</string>
<string name="screen_missing_key_backup_step_1">"Öffne Element Classic auf deinem Gerät"</string>
<string name="screen_missing_key_backup_step_2_android">"Geh zu Einstellungen &gt; Sicherheit &amp; Datenschutz"</string>
<string name="screen_missing_key_backup_step_4">"Folge den Anweisungen, um den Schlüsselspeicher zu aktivieren."</string>
<string name="screen_missing_key_backup_step_5">"Zurück zu %1$s"</string>
<string name="screen_missing_key_backup_title">"Aktiviere deinen Schlüsselspeicher, bevor du mit %1$s fortfährst."</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_checking_account">"Konto wird geprüft"</string>
<string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string>
<string name="screen_onboarding_sign_in_to">"Anmelden bei %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Vad är adressen till din server?"</string>
<string name="screen_change_server_title">"Välj din server"</string>
<string name="screen_create_account_title">"Skapa konto"</string>
<string name="screen_login_error_deactivated_account">"Detta konto har avaktiverats."</string>
<string name="screen_login_error_deactivated_account">"Detta konto har raderats."</string>
<string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string>
<string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."</string>
@@ -11,7 +11,7 @@
<string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"</string>
<string name="screen_change_account_provider_other">"其他"</string>
<string name="screen_change_account_provider_subtitle">"使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。"</string>
<string name="screen_change_account_provider_subtitle">"使用不同的帳號提供者,例如您自己的伺服器或工作帳號。"</string>
<string name="screen_change_account_provider_title">"更改帳號提供者"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s 需要 Element Pro 應用程式。請從商店下載。"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"您的伺服器地址?"</string>
<string name="screen_change_server_title">"選擇您的伺服器"</string>
<string name="screen_create_account_title">"建立帳號"</string>
<string name="screen_login_error_deactivated_account">"這個帳號已被停用。"</string>
<string name="screen_login_error_deactivated_account">"此帳號已被刪除。"</string>
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
<string name="screen_login_error_invalid_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string>
<string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string>
@@ -12,10 +12,12 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.test.runTest
@@ -156,15 +158,18 @@ class DefaultAccountProviderAccessControlTest {
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders },
),
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = {
if (elementWellKnown == null) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Success(elementWellKnown)
}
},
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = { _, _ ->
if (elementWellKnown == null) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Success(elementWellKnown)
}
},
),
),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
)
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
@@ -17,18 +17,19 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.WarmUpRule
@@ -158,7 +159,8 @@ class ChangeServerPresenterTest {
@Test
fun `present - change server element pro required error`() = runTest {
val getElementWellKnownResult = lambdaRecorder<String, WellknownRetrieverResult<ElementWellKnown>> {
val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource ->
assertThat(homeserver).isEqualTo(A_HOMESERVER_URL)
WellknownRetrieverResult.Success(
anElementWellKnown(
enforceElementPro = true,
@@ -174,8 +176,6 @@ class ChangeServerPresenterTest {
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem()
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
@@ -183,9 +183,7 @@ class ChangeServerPresenterTest {
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise")
getElementWellKnownResult.assertions()
.isCalledOnce()
.with(value(A_HOMESERVER_URL.ensureProtocol()))
getElementWellKnownResult.assertions().isCalledOnce()
}
}
@@ -269,7 +267,8 @@ class ChangeServerPresenterTest {
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
@@ -0,0 +1,108 @@
/*
* 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.login.impl.resolver
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class HomeserverResolverTest {
@Test
fun `resolve - a server name is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - an url is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("https://example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org:8448").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org:8448")),
)
}
@Test
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice").test {
awaitComplete()
}
sut.resolve("@alice:").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
@Test
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:a.b").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
private fun TestScope.createHomeserverResolver(
checkResult: (String) -> Result<Boolean>,
) = HomeserverResolver(
dispatchers = testCoroutineDispatchers(),
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
)
}
@@ -20,6 +20,7 @@ import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -30,11 +31,15 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -304,7 +309,8 @@ private fun createPresenter(
enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever,
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginModePresenter = loginModePresenter,
@@ -318,9 +324,9 @@ fun createLoginModePresenter(
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: PermissionsPresenter.Factory =
FakePermissionsPresenterFactory(),
): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
@@ -248,7 +248,7 @@ private fun ActionListViewContent(
modifier = Modifier.clickable {
onActionClick(action)
},
headlineContent = {
content = {
Text(text = stringResource(id = action.titleRes))
},
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
@@ -461,7 +461,7 @@ private fun VerifiedUserSendFailureView(
.padding(horizontal = 16.dp, vertical = 8.dp),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ErrorSolid())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
headlineContent = {
content = {
Text(
text = sendFailure.headline(),
style = ElementTheme.typography.fontBodySmMedium,
@@ -494,7 +494,7 @@ private fun VideoQualitySelectorDialog(
val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true)
val title = "${preset.title()} ($estimationMb)"
ListItem(
headlineContent = {
content = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
@@ -106,22 +106,22 @@ private fun AttachmentSourcePickerMenu(
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
)
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
)
if (state.canShareLocation) {
ListItem(
@@ -130,7 +130,7 @@ private fun AttachmentSourcePickerMenu(
onSendLocationClick()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
ListItem(
@@ -139,13 +139,13 @@ private fun AttachmentSourcePickerMenu(
onCreatePollClick()
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
content = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
if (enableTextFormatting) {
ListItem(
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
content = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
)
}
}
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesEvent
import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs(
val threadRootEventId: ThreadId,
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},
customReactionBottomSheet = {},
customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = {},
)
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
}
fun onUserDataClick() {
val sender = MatrixUser(
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
val sender = event.senderProfile.toMatrixUser(event.senderId)
onUserDataClick(sender)
}
@@ -804,15 +801,15 @@ private fun MessageEventBubbleContent(
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) {
val talkbackCompatModifier = when {
// Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason
inReplyToModifier.zIndex(1f)
} else {
inReplyToModifier.clickable(onClick = inReplyToClick)
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
else -> inReplyToModifier.clickable(onClick = inReplyToClick)
}
val contentHasError = currentContentValidationState.hasError()
val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box(
@@ -8,11 +8,16 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
modifier = modifier,
scrollable = false,
) {
ExpandSheetWhenImeIsVisible(sheetState)
emojiPickerRenderer.Render(
state = state.target.emojiPickerState,
onSelectEmoji = ::onEmojiSelectedDismiss,
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
}
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible) {
if (isImeVisible) {
sheetState.expand()
}
}
}
@@ -86,7 +86,7 @@ private fun ReadReceiptBottomSheetContent(
LazyColumn {
item {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = CommonStrings.common_seen_by))
}
)
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
private val mediaPreviewService: MediaPreviewService,
private val room: BaseRoom,
private val contentScannerService: ContentScannerService,
private val timelineProtectionStore: TimelineProtectionStore,
) : Presenter<TimelineProtectionState> {
private val allowedEvents = mutableStateSetOf<EventId>()
@Composable
override fun present(): TimelineProtectionState {
val mediaPreviewValue = remember {
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
if (isPreviewEnabled) {
ProtectionState.RenderAll
} else {
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
}
}
}
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
fun handleEvent(event: TimelineProtectionEvent) {
when (event) {
is TimelineProtectionEvent.ShowContent -> {
allowedEvents += setOfNotNull(event.eventId)
event.eventId?.let(timelineProtectionStore::allowEvent)
}
is TimelineProtectionEvent.ValidateContent -> {
contentScannerService.scan(event.mediaSources, event.validationState)
@@ -0,0 +1,32 @@
/*
* 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.messages.impl.timeline.protection
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.snapshots.SnapshotStateSet
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
interface TimelineProtectionStore {
val allowedEventIds: SnapshotStateSet<EventId>
fun allowEvent(eventId: EventId)
}
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultTimelineProtectionStore : TimelineProtectionStore {
private val allowedEvents = mutableStateSetOf<EventId>()
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
override fun allowEvent(eventId: EventId) {
allowedEvents += eventId
}
}
@@ -70,6 +70,10 @@
<string name="screen_room_timeline_reactions_show_more">"Több megjelenítése"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reakció-összefoglaló megjelenítése"</string>
<string name="screen_room_timeline_read_marker_title">"Új"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d eltávolított üzenet"</item>
<item quantity="other">"%1$d eltávolított üzenet"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d szobaváltozás"</item>
<item quantity="other">"%1$d szobaváltozás"</item>
@@ -32,7 +32,7 @@
<string name="screen_media_upload_preview_processing">"Przetwarzanie…"</string>
<string name="screen_media_upload_preview_title">"Dodaj media"</string>
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_block_user_hint">"Zaznacz, jeśli chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
@@ -7,6 +7,19 @@
<string name="crypto_event_authenticity_unknown_device">"Criptat de un dispozitiv necunoscut sau șters."</string>
<string name="crypto_event_authenticity_unsigned_device">"Criptat de un dispozitiv care nu este verificat de proprietarul său."</string>
<string name="crypto_event_authenticity_unverified_identity">"Criptat de un utilizator neverificat."</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Răsturnați imaginea pe orizontală"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Răsturnat pe orizontală"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Răsturnați imaginea pe verticală"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Răsturnat pe verticală"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotiți imaginea la stânga"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d grad"</item>
<item quantity="few">"%1$d grade"</item>
<item quantity="other">"%1$d grade"</item>
</plurals>
<string name="screen_image_edition_title">"Editați poza"</string>
<string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Atingeți pentru a modifica calitatea încărcării videoclipului"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Fișierul nu a putut fi încărcat."</string>
@@ -17,6 +30,7 @@
<string name="screen_media_upload_preview_item_count">"Elementul %1$d din %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimizați calitatea imaginii"</string>
<string name="screen_media_upload_preview_processing">"Se procesează…"</string>
<string name="screen_media_upload_preview_title">"Adăugați media"</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string>
@@ -59,6 +73,11 @@
<string name="screen_room_timeline_reactions_show_more">"Afișați mai mult"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Afișați rezumatul reacțiilor"</string>
<string name="screen_room_timeline_read_marker_title">"Nou"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d mesaj șters"</item>
<item quantity="few">"%1$d mesaje șterse"</item>
<item quantity="other">"%1$d mesaje șterse"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d schimbare a camerii"</item>
<item quantity="few">"%1$d schimbări ale camerei"</item>
@@ -7,6 +7,17 @@
<string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string>
<string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string>
<string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻轉圖片"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻轉"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始狀態"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直翻轉圖片"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"垂直翻轉"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"原始狀態"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋轉圖片"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d 度"</item>
</plurals>
<string name="screen_image_edition_title">"編輯照片"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string>
@@ -17,6 +28,7 @@
<string name="screen_media_upload_preview_item_count">"第 %1$d 個項目,共 %2$d 個"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"最佳化影像品質"</string>
<string name="screen_media_upload_preview_processing">"正在處理……"</string>
<string name="screen_media_upload_preview_title">"新增媒體"</string>
<string name="screen_report_content_block_user">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
@@ -55,6 +67,9 @@
<string name="screen_room_timeline_reactions_show_more">"更多"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"顯示反應摘要"</string>
<string name="screen_room_timeline_read_marker_title">"新訊息"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="other">"%1$d 則已移除的訊息"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes">
<item quantity="other">"%1$d 個聊天室變更"</item>
</plurals>
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
}
@Test
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetailsReady(),
eventContent = MessageContent(
body = "Replied message",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("Replied message", null),
),
textContent = "Replied message",
),
),
),
eventSink = eventsRecorder,
),
)
eventsRecorder.clear()
onAllNodesWithText("Replied message").onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
}
@Test
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
),
),
eventSink = eventsRecorder,
),
onMessageClick = {},
)
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
eventsRecorder.assertEmpty()
}
@Ignore(
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
"This needs to be reworked to use a different approach to check the LoadMore event was emitted."
@@ -0,0 +1,62 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline.components.customreaction
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() {
@Test
fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest<ComponentActivity> {
val sheetState = renderWithIme(isImeVisible = false)
assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded)
}
@Test
fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest<ComponentActivity> {
val sheetState = renderWithIme(isImeVisible = true)
assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.renderWithIme(isImeVisible: Boolean): SheetState {
lateinit var sheetState: SheetState
lateinit var view: View
setContent {
view = LocalView.current
sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
ExpandSheetWhenImeIsVisible(sheetState)
}
runOnIdle {
ViewCompat.dispatchApplyWindowInsets(
view,
WindowInsetsCompat.Builder()
.setVisible(WindowInsetsCompat.Type.ime(), isImeVisible)
.setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE)
.build()
)
}
waitForIdle()
return sheetState
}
@@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest {
}
}
@Test
fun `present - shown content is restored when the presenter is recreated`() = runTest {
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val timelineProtectionStore = DefaultTimelineProtectionStore()
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - validate media scans the media source`() = runTest {
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
room: BaseRoom = FakeBaseRoom(),
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
) = TimelineProtectionPresenter(
mediaPreviewService = mediaPreviewService,
room = room,
contentScannerService = contentScannerService,
timelineProtectionStore = timelineProtectionStore,
)
}
@@ -107,7 +107,7 @@ fun CreatePollView(
item {
Column {
ListItem(
headlineContent = {
content = {
TextField(
label = stringResource(id = R.string.screen_create_poll_question_desc),
value = state.question,
@@ -127,7 +127,7 @@ fun CreatePollView(
itemsIndexed(state.answers) { index, answer ->
val isLastItem = index == state.answers.size - 1
ListItem(
headlineContent = {
content = {
TextField(
value = answer.text,
onValueChange = {
@@ -155,7 +155,7 @@ fun CreatePollView(
if (state.canAddAnswer) {
item {
ListItem(
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
content = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.Plus()),
),
@@ -174,7 +174,7 @@ fun CreatePollView(
Column {
HorizontalDivider()
ListItem(
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
trailingContent = ListItemContent.Switch(
checked = state.pollKind == PollKind.Undisclosed,
@@ -189,7 +189,7 @@ fun CreatePollView(
)
if (state.canDelete) {
ListItem(
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
content = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
style = ListItemStyle.Destructive,
onClick = { state.eventSink(CreatePollEvent.Delete(confirmed = false)) },
)
@@ -34,14 +34,14 @@ fun AboutView(
) {
state.elementLegals.forEach { elementLegal ->
ListItem(
headlineContent = {
content = {
Text(stringResource(id = elementLegal.titleRes))
},
onClick = { onElementLegalClick(elementLegal) }
)
}
ListItem(
headlineContent = {
content = {
Text(stringResource(id = CommonStrings.common_open_source_licenses))
},
onClick = onOpenSourceLicensesClick,
@@ -93,7 +93,7 @@ fun AdvancedSettingsView(
}
)
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = CommonStrings.action_view_source))
},
supportingContent = {
@@ -105,7 +105,7 @@ fun AdvancedSettingsView(
onClick = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) }
)
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence))
},
supportingContent = {
@@ -122,7 +122,7 @@ fun AdvancedSettingsView(
null -> Unit
is MediaOptimizationState.AllMedia -> {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
},
supportingContent = {
@@ -146,7 +146,7 @@ fun AdvancedSettingsView(
}
is MediaOptimizationState.Split -> {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title))
},
supportingContent = {
@@ -171,7 +171,7 @@ fun AdvancedSettingsView(
var displaySelectorDialog by remember { mutableStateOf(false) }
ListItem(
headlineContent = {
content = {
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title))
},
supportingContent = {
@@ -247,7 +247,7 @@ private fun VideoQualitySelectorDialog(
VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description)
}
ListItem(
headlineContent = {
content = {
Text(
text = title,
style = ElementTheme.typography.fontBodyLgMedium,
@@ -301,7 +301,7 @@ private fun ModerationAndSafety(
}
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off,
compact = true
@@ -312,7 +312,7 @@ private fun ModerationAndSafety(
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private,
compact = true
@@ -323,7 +323,7 @@ private fun ModerationAndSafety(
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
)
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
leadingContent = ListItemContent.RadioButton(
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On,
compact = true
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
sealed interface BlockedUsersEvents {
data class Unblock(val userId: UserId) : BlockedUsersEvents
data class CopyToClipboard(val userId: UserId) : BlockedUsersEvents
data object ConfirmUnblock : BlockedUsersEvents
data object Cancel : BlockedUsersEvents
}
@@ -18,14 +18,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -34,6 +39,8 @@ import kotlinx.coroutines.launch
class BlockedUsersPresenter(
private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService,
private val clipboardHelper: ClipboardHelper,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<BlockedUsersState> {
@Composable
override fun present(): BlockedUsersState {
@@ -65,12 +72,18 @@ class BlockedUsersPresenter(
}
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
fun handleEvent(event: BlockedUsersEvents) {
when (event) {
is BlockedUsersEvents.Unblock -> {
pendingUserToUnblock = event.userId
unblockUserAction.value = AsyncAction.ConfirmingNoParams
}
is BlockedUsersEvents.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.userId.value)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
BlockedUsersEvents.ConfirmUnblock -> {
pendingUserToUnblock?.let {
coroutineScope.unblockUser(it, unblockUserAction)
@@ -86,6 +99,7 @@ class BlockedUsersPresenter(
return BlockedUsersState(
blockedUsers = ignoredMatrixUser.toImmutableList(),
unblockUserAction = unblockUserAction.value,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,
)
}
@@ -9,11 +9,13 @@
package io.element.android.features.preferences.impl.blockedusers
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class BlockedUsersState(
val blockedUsers: ImmutableList<MatrixUser>,
val unblockUserAction: AsyncAction<Unit>,
val snackbarMessage: SnackbarMessage?,
val eventSink: (BlockedUsersEvents) -> Unit,
)
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList
@@ -30,11 +31,13 @@ class BlockedUsersStateProvider : PreviewParameterProvider<BlockedUsersState> {
internal fun aBlockedUsersState(
blockedUsers: List<MatrixUser> = aMatrixUserList(),
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
snackbarMessage: SnackbarMessage? = null,
eventSink: (BlockedUsersEvents) -> Unit = {},
): BlockedUsersState {
return BlockedUsersState(
blockedUsers = blockedUsers.toImmutableList(),
unblockUserAction = unblockUserAction,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
}
@@ -8,7 +8,7 @@
package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
@@ -31,6 +31,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
@@ -43,6 +45,7 @@ fun BlockedUsersView(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Box(modifier = modifier) {
Scaffold(
topBar = {
@@ -52,7 +55,8 @@ fun BlockedUsersView(
BackButton(onClick = onBackClick)
}
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding)
@@ -60,7 +64,8 @@ fun BlockedUsersView(
items(state.blockedUsers) { matrixUser ->
BlockedUserItem(
matrixUser = matrixUser,
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) }
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) },
onLongClick = { state.eventSink(BlockedUsersEvents.CopyToClipboard(it)) },
)
}
}
@@ -107,9 +112,14 @@ fun BlockedUsersView(
private fun BlockedUserItem(
matrixUser: MatrixUser,
onClick: (UserId) -> Unit,
onLongClick: (UserId) -> Unit,
) {
MatrixUserRow(
modifier = Modifier.clickable { onClick(matrixUser.userId) },
modifier = Modifier.combinedClickable(
onClick = { onClick(matrixUser.userId) },
onLongClick = { onLongClick(matrixUser.userId) },
onLongClickLabel = stringResource(CommonStrings.action_copy),
),
matrixUser = matrixUser,
)
}
@@ -31,6 +31,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
@@ -41,6 +42,7 @@ import kotlinx.coroutines.launch
class DeveloperSettingsPresenter(
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
private val sessionId: SessionId,
private val deviceId: DeviceId,
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
private val clearCacheUseCase: ClearCacheUseCase,
private val enterpriseService: EnterpriseService,
@@ -117,6 +119,7 @@ class DeveloperSettingsPresenter(
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
eventSink = ::handleEvent,
)
}
@@ -11,6 +11,7 @@ package io.element.android.features.preferences.impl.developer
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.DeviceId
import kotlinx.collections.immutable.ImmutableMap
data class DeveloperSettingsState(
@@ -21,6 +22,7 @@ data class DeveloperSettingsState(
val markAllRoomsAsReadAction: AsyncAction<Unit>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val deviceId: DeviceId,
val eventSink: (DeveloperSettingsEvents) -> Unit
) {
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
@@ -13,6 +13,7 @@ import io.element.android.features.preferences.impl.developer.appsettings.AppDev
import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.DeviceId
import kotlinx.collections.immutable.persistentMapOf
open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> {
@@ -40,6 +41,7 @@ fun aDeveloperSettingsState(
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
eventSink: (DeveloperSettingsEvents) -> Unit = {},
) = DeveloperSettingsState(
appDeveloperSettingsState = appDeveloperSettingsState,
@@ -49,5 +51,6 @@ fun aDeveloperSettingsState(
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
eventSink = eventSink,
)

Some files were not shown because too many files have changed in this diff Show More