Merge branch 'develop' into fix/2614-linkify-ipv6-urls

This commit is contained in:
Benoit Marty
2026-08-12 13:48:36 +02:00
committed by GitHub
739 changed files with 6362 additions and 3368 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
+2 -2
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
+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@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
- 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
+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
+2 -2
View File
@@ -63,12 +63,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' }}
+92
View File
@@ -1,3 +1,95 @@
Changes in Element X v26.08.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.1 -->
## What's Changed
### 🙌 Improvements
* Render push notifications for live location share starts (MSC4505) by @ara4n in https://github.com/element-hq/element-x-android/pull/7203
* [Task] Show active call participants in the people list #7199 by @Eklavya47 in https://github.com/element-hq/element-x-android/pull/7300
### 🐛 Bugfixes
* Fix overlap issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/7331
* Announce checked/unchecked state for user rows in invite screen by @bmarty in https://github.com/element-hq/element-x-android/pull/7136
* Display 'add reactions' menu in the threads screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7388
* Expand the emoji picker to full height when the keyboard is shown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7382
* UI fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/7409
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7389
### 🧱 Build
* Don't check enterprise previews in `KonsistPreviewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7297
* CI: split unit tests, screenshots and coverage in `tests.yml` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7296
* Update Gradle Wrapper from 9.6.1 to 9.7.0 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7335
### 🚧 In development 🚧
* QR code login iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/6719
* User status last pieces by @ganfra in https://github.com/element-hq/element-x-android/pull/7328
* [Link new device] Add timeout by @bmarty in https://github.com/element-hq/element-x-android/pull/7338
### Dependency upgrades
* Update dependency org.maplibre.compose:maplibre-compose to v0.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7291
* Update android.gradle.plugin to v9.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7248
* Update dependency io.sentry:sentry-android to v8.50.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7298
* Update zizmorcore/zizmor-action action to v0.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7306
* Update android.gradle.plugin to v9.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7304
* Update dependency com.posthog:posthog-android to v3.56.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7311
* Update dependencyAnalysis to v3.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7318
* Update actions/stale action to v11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7317
* Update dependency io.github.zxing-cpp:android to v3.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7322
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.05 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7324
* Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7329
* Update reactivecircus/android-emulator-runner action to v2.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7189
* Update dependency io.sentry:sentry-android to v8.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7301
* Update dependency org.maplibre.gl:android-sdk to v13.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7305
* Update dependency io.element.android:element-call-embedded to v0.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7323
* Update metro to v1.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7334
* Update dependency com.google.firebase:firebase-bom to v34.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7333
* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7175
* Update dependency org.jsoup:jsoup to v1.23.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7332
* Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7330
* Update zizmorcore/zizmor-action action to v0.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7383
* Update actions/setup-java action to v5.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7339
* Update gradle/actions action to v6.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7384
* Update plugin dependencycheck to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7386
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7402
### 💼 Element Pro
* Add support for checking the ESS config in Element Pro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7302
### Others
* Add extra vertical padding to `ReplyToInvalidContent` and `ReplyToNotFoundContent` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7288
* Index messages locally in an encrypted on-device store so encrypted rooms can be searched at all by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7249
* Bump enterprise module to the latest commit by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7307
* Bump enterprise module to the latest commit by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7309
* Move deviceId and git details to developer settings by @bmarty in https://github.com/element-hq/element-x-android/pull/7241
* Show a Message not found placeholder when a replied to message cannot be loaded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7376
* Update in reply to message when message is not found. by @bmarty in https://github.com/element-hq/element-x-android/pull/7387
* Add default `OAUTH_CLIENT_URL_PATH` alternative by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7385
* Fix the feature module template writing the impl build script into the api module by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7374
* Let users enter their full Matrix user ID when searching for a homeserver by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7375
* Keep manually revealed media visible when re-entering a room by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7378
* Copy a blocked user's Matrix ID by long pressing them by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7372
* Do not draw the media loading progress bar under the status bar by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7366
* Do not notify about an event sent by another account on the same device by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7360
* Keep the opened image on screen when the media gallery fails to load by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7362
* Do not linkify the server part of a fediverse handle as an email address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7356
* Expose global account data get and set on MatrixClient by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7373
* Show the alias or the direct message user's Matrix ID in the room picker by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7371
* Use a dedicated string for the Mute room notification mode by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7367
* Keep playing a voice message when the screen turns off by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7365
* Let a voice message without a waveform be seeked by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7363
* Ignore a second voice recording start while one is already running by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7346
* Do not offer to start a call in a DM with no other member by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7352
* Do not notify about a redaction whose target event is unknown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7350
* Ignore a room list filter that is incompatible with an already selected one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7342
* Stop a right to left sender name from reordering a room list preview by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7348
* Do not append the application MapTiler key to a custom map style url by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7340
* Hide space children you have left that can only be joined by invitation by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7369
* Never re-encode a video at a higher bitrate than the source by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7351
* Display push notifications for knocks by @ara4n in https://github.com/element-hq/element-x-android/pull/7207
## New Contributors
* @hayaksi1 made their first contribution in https://github.com/element-hq/element-x-android/pull/7249
* @ara4n made their first contribution in https://github.com/element-hq/element-x-android/pull/7203
* @Eklavya47 made their first contribution in https://github.com/element-hq/element-x-android/pull/7300
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.0...v26.08.1
Changes in Element X v26.08.0
=============================
+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
@@ -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
}
@@ -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()
}
@@ -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 {
@@ -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>
@@ -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.")
@@ -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")
}
}
@@ -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
}
}
@@ -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,
@@ -0,0 +1,39 @@
/*
* 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.pinned
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
internal fun List<MatrixTimelineItem>.keepDisplayablePinnedEvents(): List<MatrixTimelineItem> {
val items = filter { it.isDisplayableAsPinnedEvent() }
return items.filterIndexed { index, item ->
!item.isDayDivider() || items.getOrNull(index + 1) is MatrixTimelineItem.Event
}
}
internal fun EventContent.isDisplayableAsPinnedEvent(): Boolean {
return when (this) {
UnknownContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent -> false
else -> true
}
}
private fun MatrixTimelineItem.isDisplayableAsPinnedEvent(): Boolean {
return this !is MatrixTimelineItem.Event || event.content.isDisplayableAsPinnedEvent()
}
private fun MatrixTimelineItem.isDayDivider(): Boolean {
return this is MatrixTimelineItem.Virtual && virtual is VirtualTimelineItem.DayDivider
}
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString
import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.pinned.isDisplayableAsPinnedEvent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -24,6 +25,7 @@ class PinnedMessagesBannerItemFactory(
when (timelineItem) {
is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@withContext null
if (!timelineItem.event.content.isDisplayableAsPinnedEvent()) return@withContext null
val formatted = formatter.format(timelineItem.event)
PinnedMessagesBannerItem(
eventId = eventId,
@@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
@@ -207,7 +208,7 @@ class PinnedMessagesListPresenter(
val timelineItemsFlow = asyncTimeline.data.timelineItems
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith(
timelineItems = items,
timelineItems = items.keepDisplayablePinnedEvents(),
roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = false,
)
@@ -239,7 +240,7 @@ class PinnedMessagesListPresenter(
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
is AsyncData.Failure -> PinnedMessagesListState.Failed
is AsyncData.Success -> {
if (timelineItems.data.isEmpty()) {
if (timelineItems.data.all { it is TimelineItem.Virtual }) {
PinnedMessagesListState.Empty
} else {
val actionListState = actionListPresenter.present()
@@ -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()
}
}
}
@@ -58,10 +58,12 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
is TimelineItemFileContent,
is TimelineItemAudioContent,
is TimelineItemVideoContent,
is TimelineItemLocationContent,
is TimelineItemVoiceContent,
is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true
// Live location shares can't be forwarded, the SDK rejects them, so we only show the option for static locations
// See https://github.com/element-hq/element-x-android/issues/7190
is TimelineItemLocationContent -> mode is TimelineItemLocationContent.Mode.Static
// Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false
@@ -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>
@@ -21,7 +21,10 @@ import io.element.android.features.messages.impl.timeline.model.event.RtcNotific
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aLiveLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
@@ -601,6 +604,123 @@ class ActionListPresenterTest {
}
}
@Test
fun `present - compute for a static location item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
),
recentEmojis = suggestedEmojis,
)
)
initialState.eventSink.invoke(ActionListEvent.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a live location item does not offer to forward it`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemLocationContent(mode = aLiveLocationMode(isActive = true)),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
),
recentEmojis = suggestedEmojis,
)
)
initialState.eventSink.invoke(ActionListEvent.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for an ended live location item does not offer to forward it`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemLocationContent(mode = aLiveLocationMode(isActive = false)),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat((successState.target as ActionListState.Target.Success).actions)
.doesNotContain(TimelineItemAction.Forward)
}
}
@Test
fun `present - compute for a media with caption item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
@@ -0,0 +1,74 @@
/*
* 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.pinned.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinnedMessagesBannerItemFactoryTest {
@Test
fun `create - returns an item for an event which can be displayed`() = runTest {
val factory = createPinnedMessagesBannerItemFactory()
val item = factory.create(anEventTimelineItemOf(aMessageContent("A message")))
assertThat(item?.eventId).isEqualTo(AN_EVENT_ID)
}
@Test
fun `create - returns null for events which cannot be displayed`() = runTest {
val factory = createPinnedMessagesBannerItemFactory()
val contents = listOf(
UnknownContent,
FailedToParseMessageLikeContent(eventType = "m.room.message", error = "an error"),
FailedToParseStateContent(eventType = "m.room.topic", stateKey = "", error = "an error"),
)
for (content in contents) {
assertThat(factory.create(anEventTimelineItemOf(content))).isNull()
}
}
@Test
fun `create - returns null for virtual items`() = runTest {
val factory = createPinnedMessagesBannerItemFactory()
val item = factory.create(
MatrixTimelineItem.Virtual(
uniqueId = A_UNIQUE_ID,
virtual = VirtualTimelineItem.DayDivider(timestamp = 0L),
)
)
assertThat(item).isNull()
}
private fun anEventTimelineItemOf(content: EventContent) = MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = content,
),
)
private fun TestScope.createPinnedMessagesBannerItemFactory() = PinnedMessagesBannerItemFactory(
coroutineDispatchers = testCoroutineDispatchers(),
formatter = FakePinnedMessagesBannerFormatter(
formatLambda = { event -> "${event.content}" }
),
)
}
@@ -14,6 +14,7 @@ import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBanner
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
@@ -24,6 +25,7 @@ import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.flowOf
@@ -90,6 +92,46 @@ class PinnedMessagesBannerPresenterTest {
}
}
@Test
fun `present - loaded state - events which cannot be displayed are not rendered`() = runTest {
val messageContent = aMessageContent("A message")
val pinnedEventsTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = UnknownContent,
),
)
)
)
)
val room = FakeJoinedRoom(
createTimelineResult = { Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
val loadedState = consumeItemsUntilPredicate { state ->
state is PinnedMessagesBannerState.Loaded
}.last() as PinnedMessagesBannerState.Loaded
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - loaded state - multiple pinned messages`() = runTest {
val messageContent1 = aMessageContent("A message")
@@ -21,13 +21,19 @@ import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvi
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
@@ -38,6 +44,7 @@ import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -131,6 +138,90 @@ class PinnedMessagesListPresenterTest {
}
}
@Test
fun `present - filled state - events which cannot be displayed are not rendered`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline(
items = listOf(
MatrixTimelineItem.Virtual(
uniqueId = UniqueId("aDayDivider"),
virtual = VirtualTimelineItem.DayDivider(timestamp = 0L),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = aMessageContent("A message"),
),
),
MatrixTimelineItem.Virtual(
uniqueId = UniqueId("anotherDayDivider"),
virtual = VirtualTimelineItem.DayDivider(timestamp = 1L),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = UnknownContent,
),
),
),
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
},
createTimelineResult = { Result.success(pinnedEventsTimeline) },
)
val presenter = createPinnedMessagesListPresenter(room = room)
presenter.test {
val filledState = consumeItemsUntilPredicate { state ->
state is PinnedMessagesListState.Filled
}.last() as PinnedMessagesListState.Filled
assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(filledState.timelineItems).hasSize(2)
assertThat(filledState.timelineItems.filterIsInstance<TimelineItem.Event>().map { it.eventId }).containsExactly(AN_EVENT_ID)
assertThat(filledState.timelineItems.filterIsInstance<TimelineItem.Virtual>()).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - empty state when no pinned event can be displayed`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline(
items = listOf(
MatrixTimelineItem.Virtual(
uniqueId = A_UNIQUE_ID,
virtual = VirtualTimelineItem.DayDivider(timestamp = 0L),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = FailedToParseMessageLikeContent(eventType = "m.room.message", error = "an error"),
),
),
),
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
createTimelineResult = { Result.success(pinnedEventsTimeline) },
)
val presenter = createPinnedMessagesListPresenter(room = room)
presenter.test {
val emptyState = consumeItemsUntilPredicate { state ->
state is PinnedMessagesListState.Empty
}.last()
assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - unpin event`() = runTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
@@ -263,21 +354,18 @@ class PinnedMessagesListPresenterTest {
}
}
private fun createPinnedMessagesTimeline(): FakeTimeline {
val messageContent = aMessageContent("A message")
return FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
),
)
)
private fun createPinnedMessagesTimeline(
items: List<MatrixTimelineItem> = listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = aMessageContent("A message"),
),
)
)
),
): FakeTimeline {
return FakeTimeline(timelineItems = flowOf(items))
}
private fun roomPermissions(
@@ -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,
)
}
@@ -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,
)
}
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
val isUserStatusSupported by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.collect { isUserStatusEnabled ->
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
}
}
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -8,10 +8,14 @@
package io.element.android.features.preferences.impl.root
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,7 +29,10 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -71,71 +78,87 @@ fun PreferencesRootView(
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// Include pref from other modules
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
Box(modifier = modifier) {
// Include pref from other modules
PreferencePage(
onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) }
) {
UserPreferences(
modifier = Modifier.clickable {
onOpenUserProfile(state.myUser)
},
matrixUser = state.myUser,
)
}
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
)
}
)
if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
)
}
state.userStatusState?.let {
UserStatusUpdateIndicator(it.updateStatusAction)
}
}
}
@Composable
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
}
@Composable
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
@@ -315,7 +338,9 @@ private fun ColumnScope.GeneralSection(
)
}
// Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry
if (state.showDeveloperSettings) {
AnimatedVisibility(
visible = state.showDeveloperSettings,
) {
DeveloperPreferencesView(onOpenDeveloperSettings)
}
}
@@ -18,7 +18,9 @@ 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.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient
@@ -38,6 +40,7 @@ class UserStatusPresenter(
var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: UserStatusEvent) {
when (event) {
@@ -47,11 +50,15 @@ class UserStatusPresenter(
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
}
}
UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
}
}
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
@@ -93,6 +100,7 @@ class UserStatusPresenter(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = effectivePickerState,
updateStatusAction = updateStatusAction.value,
eventSink = ::handleEvent,
)
}
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -19,6 +20,7 @@ data class UserStatusState(
val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState,
val updateStatusAction: AsyncAction<Unit>,
val eventSink: (UserStatusEvent) -> Unit,
)
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -44,10 +45,12 @@ fun aUserStatusState(
displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState(
displayedStatus = displayedStatus,
rawStatus = rawStatus,
pickerState = pickerState,
updateStatusAction = updateStatusAction,
eventSink = eventSink,
)
@@ -11,6 +11,12 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Avatare in Chateinladungen ausblenden"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Medienvorschau im Nachrichtenverlauf ausblenden"</string>
<string name="screen_advanced_settings_labs">"Labs"</string>
<string name="screen_advanced_settings_live_location_section_description">"Die Entfernung, die du zurücklegen musst, um ein Update auszulösen."</string>
<string name="screen_advanced_settings_live_location_section_title">"Live-Standort-Updates"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="one">"Alle %1$d Meter"</item>
<item quantity="other">"Alle %1$d Meter"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimiere die Medienqualität"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation und Sicherheit"</string>
@@ -101,6 +101,14 @@ Ha folytatja, egyes beállítások megváltozhatnak."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"rendszerbeállításokat"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"A rendszerértesítések ki vannak kapcsolva"</string>
<string name="screen_notification_settings_title">"Értesítések"</string>
<string name="screen_settings_user_status_away">"Távol"</string>
<string name="screen_settings_user_status_be_right_back">"Rögtön visszajövök"</string>
<string name="screen_settings_user_status_custom">"Egyedi…"</string>
<string name="screen_settings_user_status_custom_hint">"Egyéni állapot…"</string>
<string name="screen_settings_user_status_focus_time">"Feladatra koncentrál"</string>
<string name="screen_settings_user_status_in_a_meeting">"Értekezleten"</string>
<string name="screen_settings_user_status_on_the_road">"Úton"</string>
<string name="screen_settings_user_status_placeholder">"Mi az állapota?"</string>
<string name="theme_black">"Fekete"</string>
<string name="theme_dark">"Sötét"</string>
<string name="theme_light">"Világos"</string>
@@ -33,15 +33,15 @@
<string name="screen_advanced_settings_push_provider_android">"Dostawca powiadomień push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potwierdzenia odczytania"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane."</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Po wyłączeniu potwierdzenia odczytu nie będą wysyłane. Nadal będziesz otrzymywać potwierdzenia od innych użytkowników."</string>
<string name="screen_advanced_settings_share_presence">"Udostępnij obecność"</string>
<string name="screen_advanced_settings_share_presence_description">"Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania."</string>
<string name="screen_advanced_settings_share_presence_description">"Po wyłączeniu nie będzie można wysyłać ani odbierać potwierdzeń odczytu i powiadomień o pisaniu."</string>
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Zawsze ukrywaj"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"Zawsze pokazuj"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"W pokojach prywatnych"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Ukryte media można zawsze wyświetlić, dotykając ich"</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Pokaż media na osi czasu"</string>
<string name="screen_advanced_settings_view_source_description">"Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu."</string>
<string name="screen_advanced_settings_view_source_description">"Włącz, aby móc wyświetlać źródło wiadomości na osi czasu."</string>
<string name="screen_blocked_users_empty">"Nie blokujesz żadnych użytkowników"</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokuj"</string>
<string name="screen_blocked_users_unblock_alert_description">"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."</string>
@@ -59,6 +59,7 @@
<string name="screen_labs_header_title">"Doriți experiențe noi?"</string>
<string name="screen_labs_title">"Laboratoare"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Setări adiționale"</string>
<string name="screen_notification_settings_call_ringtone_label">"Ton de apel"</string>
<string name="screen_notification_settings_calls_label">"Apeluri audio și video"</string>
<string name="screen_notification_settings_configuration_mismatch">"Nepotrivire de configurație"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit.
@@ -79,14 +80,38 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate."</st
<string name="screen_notification_settings_invite_for_me_label">"Invitații"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."</string>
<string name="screen_notification_settings_mentions_section_title">"Mențiuni"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Alegeți un alt sunet…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Se utilizează în prezent %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Sunet de mesaj"</string>
<string name="screen_notification_settings_message_sound_label">"Sunet de mesaj"</string>
<string name="screen_notification_settings_mode_all">"Toate"</string>
<string name="screen_notification_settings_mode_mentions">"Mențiuni"</string>
<string name="screen_notification_settings_notification_section_title">"Anunță-mă pentru"</string>
<string name="screen_notification_settings_room_mention_label">"Anunțați-mă pentru @room"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Personalizat"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Sunet personalizat…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Eroare la ștergerea fișierului"</string>
<string name="screen_notification_settings_sound_element_default">"Element Implicit"</string>
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Eroare la importul fișierului"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"A fost intâmpinată o problemă la previzualizarea sunetului de alertă"</string>
<string name="screen_notification_settings_sound_section_title">"Sunet"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Ignorați eroarea privind sunetul de alertă"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"A fost întâmpinată o problemă la setarea sunetului de alertă"</string>
<string name="screen_notification_settings_sound_silent">"Silențios"</string>
<string name="screen_notification_settings_sound_system_default">"Implicit sistem"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Pentru a primi notificări, vă rugăm să vă schimbați %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"Setări de sistem"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Notificările de sistem sunt dezactivate"</string>
<string name="screen_notification_settings_title">"Notificări"</string>
<string name="screen_settings_user_status_away">"Plecat"</string>
<string name="screen_settings_user_status_be_right_back">"Revin imediat"</string>
<string name="screen_settings_user_status_custom">"Personalizat…"</string>
<string name="screen_settings_user_status_custom_hint">"Status personalizat…"</string>
<string name="screen_settings_user_status_focus_time">"Timp de concentrare"</string>
<string name="screen_settings_user_status_in_a_meeting">"Într-o ședință"</string>
<string name="screen_settings_user_status_on_the_road">"Pe drum"</string>
<string name="screen_settings_user_status_placeholder">"Care este statusul dumneavoastră?"</string>
<string name="theme_black">"Negru"</string>
<string name="theme_dark">"Întunecat"</string>
<string name="theme_light">"Deschis"</string>
@@ -11,6 +11,13 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"在聊天室邀請請求中隱藏大頭貼"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"在時間軸中隱藏媒體預覽"</string>
<string name="screen_advanced_settings_labs">"實驗室"</string>
<string name="screen_advanced_settings_live_location_section_description">"觸發更新所需的移動距離。"</string>
<string name="screen_advanced_settings_live_location_section_footer">"請確保已為此應用程式啟用「精確位置」功能。若要變更此權限,請前往 %1$s。"</string>
<string name="screen_advanced_settings_live_location_section_footer_link">"應用程式設定"</string>
<string name="screen_advanced_settings_live_location_section_title">"即時位置更新"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="other">"每 %1$d 公尺"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"上傳照片與影片更快且減少資料使用量"</string>
<string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"管理與安全"</string>
@@ -50,6 +57,7 @@
<string name="screen_labs_header_title">"想不想來點新花樣?"</string>
<string name="screen_labs_title">"實驗室"</string>
<string name="screen_notification_settings_additional_settings_section_title">"其他設定"</string>
<string name="screen_notification_settings_call_ringtone_label">"通話鈴聲"</string>
<string name="screen_notification_settings_calls_label">"音訊與視訊通話"</string>
<string name="screen_notification_settings_configuration_mismatch">"組態錯誤"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。
@@ -68,14 +76,39 @@
<string name="screen_notification_settings_invite_for_me_label">"邀請"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。"</string>
<string name="screen_notification_settings_mentions_section_title">"提及"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"選擇其他音效⋯⋯"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"目前正在使用 %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"訊息音效"</string>
<string name="screen_notification_settings_message_sound_label">"訊息音效"</string>
<string name="screen_notification_settings_mode_all">"全部"</string>
<string name="screen_notification_settings_mode_mentions">"提及"</string>
<string name="screen_notification_settings_notification_section_title">"通知我"</string>
<string name="screen_notification_settings_room_mention_label">"於 @room 通知我"</string>
<string name="screen_notification_settings_sound_custom_fallback">"自訂"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"自訂音效⋯⋯"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"刪除檔案時發生錯誤"</string>
<string name="screen_notification_settings_sound_element_default">"Element 預設值"</string>
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"匯入檔案時發生錯誤"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"預覽警示音時發生問題"</string>
<string name="screen_notification_settings_sound_section_title">"音效"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"忽略警示音錯誤"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"設定警示音時發生問題"</string>
<string name="screen_notification_settings_sound_silent">"靜音"</string>
<string name="screen_notification_settings_sound_system_default">"系統預設值"</string>
<string name="screen_notification_settings_system_notifications_action_required">"要收到通知,請變更您的 %1$s。"</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"系統設定"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"已關閉系統通知"</string>
<string name="screen_notification_settings_title">"通知"</string>
<string name="screen_settings_user_status_away">"離開"</string>
<string name="screen_settings_user_status_be_right_back">"稍後回來"</string>
<string name="screen_settings_user_status_custom">"自訂⋯⋯"</string>
<string name="screen_settings_user_status_custom_hint">"自訂狀態⋯⋯"</string>
<string name="screen_settings_user_status_focus_time">"專注時間"</string>
<string name="screen_settings_user_status_in_a_meeting">"在會議中"</string>
<string name="screen_settings_user_status_on_the_road">"在路上"</string>
<string name="screen_settings_user_status_placeholder">"您的狀態如何?"</string>
<string name="theme_black">"黑色"</string>
<string name="theme_dark">"深色"</string>
<string name="theme_light">"淺色"</string>
<string name="theme_system">"系統"</string>
@@ -13,8 +13,10 @@ package io.element.android.features.preferences.impl.blockedusers
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
@@ -57,6 +59,20 @@ class BlockedUserViewTest : RobolectricTest() {
eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId))
}
@Test
fun `long clicking on a user emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
val userList = aMatrixUserList()
setBlockedUsersView(
aBlockedUsersState(
blockedUsers = userList,
eventSink = eventsRecorder
),
)
onNodeWithText(userList.first().displayName.orEmpty()).performTouchInput { longClick() }
eventsRecorder.assertSingle(BlockedUsersEvents.CopyToClipboard(userList.first().userId))
}
@Test
fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
@@ -12,7 +12,9 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -21,6 +23,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -169,6 +172,29 @@ class BlockedUsersPresenterTest {
}
}
@Test
fun `present - copy user id to clipboard`() = runTest {
val clipboardHelper = FakeClipboardHelper()
val matrixClient = FakeMatrixClient(
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
)
val presenter = aBlockedUsersPresenter(
matrixClient = matrixClient,
clipboardHelper = clipboardHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.snackbarMessage).isNull()
initialState.eventSink(BlockedUsersEvents.CopyToClipboard(A_USER_ID))
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_USER_ID.value)
assertThat(awaitItem().snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - confirm unblock without a pending blocked user does nothing`() = runTest {
val presenter = aBlockedUsersPresenter()
@@ -183,8 +209,12 @@ class BlockedUsersPresenterTest {
private fun aBlockedUsersPresenter(
matrixClient: FakeMatrixClient = FakeMatrixClient(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
) = BlockedUsersPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
clipboardHelper = clipboardHelper,
snackbarDispatcher = snackbarDispatcher,
)
}
@@ -8,9 +8,11 @@
package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `Set event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
setUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// On failure the SDK does not emit a new profile, so nothing is displayed.
assertThat(newState.displayedStatus).isNull()
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull()
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@Test
fun `Clear event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
clearUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// Clear failed, so the previously set status is still displayed.
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@@ -8,6 +8,8 @@
<string name="screen_bug_report_editor_placeholder">"Descrieți problema…"</string>
<string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string>
<string name="screen_bug_report_error_description_too_short">"Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!"</string>
<string name="screen_bug_report_github_issue_description">"Puteți introduce numărul unui tichet GitHub asociat, dacă există."</string>
<string name="screen_bug_report_github_issue_label">"Tichet GitHub"</string>
<string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string>
<string name="screen_bug_report_include_logs">"Permiteți log-uri"</string>
<string name="screen_bug_report_include_logs_error">"Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă."</string>

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