diff --git a/.editorconfig b/.editorconfig
index d2f28922d9b..a41ddcad7f9 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -35,6 +35,7 @@ ktlint_standard_class-signature = disabled
ktlint_standard_when-entry-bracing = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
ktlint_standard_mixed-condition-operators = disabled
+ktlint_standard_no-unused-imports = enabled
[*.java]
ij_java_align_consecutive_assignments = false
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index dc95d0ff492..f46322c967d 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -47,6 +47,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
@@ -54,7 +55,7 @@ jobs:
run: ./gradlew :app:assembleGplayTchapWithpinningDebug app:assembleFDroidTchapWithpinningDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-debug
path: |
@@ -62,7 +63,7 @@ jobs:
app/build/outputs/apk/fdroidTchapWithpinning/debug/*-universal-debug.apk
- name: Upload x86_64 APK for Maestro
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-apk-maestro
path: |
@@ -105,7 +106,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
@@ -113,7 +114,7 @@ jobs:
run: ./gradlew :app:assembleGplayTchapWithpinningNightly -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload Tchap X nightly APKs
if: ${{ matrix.variant == 'nightly' }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: Tchap.X.beta-nightly
path: |
@@ -124,7 +125,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
@@ -132,7 +133,7 @@ jobs:
run: ./gradlew :app:assembleGplaybTchapWithpinningNightly -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload Btchap X nightly APKs
if: ${{ matrix.variant == 'nightly' }}
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: Btchap.X.beta-nightly
path: |
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index 51ac46b11fd..f60b25d5d99 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -54,6 +54,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
@@ -61,7 +62,7 @@ jobs:
run: ./gradlew :app:assembleGplayTchapWithpinningDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-enterprise-debug
path: |
diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml
index a77a3ae214c..0d4585dbd0d 100644
--- a/.github/workflows/maestro-local.yml
+++ b/.github/workflows/maestro-local.yml
@@ -45,7 +45,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-apk-maestro
path: |
@@ -71,7 +71,7 @@ jobs:
ref: ${{ github.ref }}
lfs: true # Tchap TODO : Remove this when maven is using matrix-sdk from Tchap repository
- name: Download APK artifact from previous job
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
with:
name: elementx-apk-maestro
- name: Enable KVM group perms
@@ -104,7 +104,7 @@ jobs:
script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-tchap-withpinning-x86_64-debug.apk
- name: Upload test results
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: test-results
path: |
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 8bf33941141..9f517494a59 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -31,6 +31,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 2788e13576c..f46c746054b 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -43,7 +43,7 @@ jobs:
- name: ✅ Upload kover report
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: kover-results
path: |
@@ -75,7 +75,7 @@ jobs:
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 471aeb77b8e..4345dcc9347 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -102,7 +102,7 @@ jobs:
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: konsist-report
path: |
@@ -182,7 +182,7 @@ jobs:
run: ./gradlew :app:lintGplayTchapWithpinningDebug :app:lintFdroidTchapWithpinningDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: linting-report
path: |
@@ -225,7 +225,7 @@ jobs:
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: detekt-report
path: |
@@ -268,7 +268,7 @@ jobs:
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
- name: Upload reports
if: always()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: ktlint-report
path: |
@@ -334,7 +334,7 @@ jobs:
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Download reports from previous jobs
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v7
- name: Prepare Danger
if: always()
run: |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 96ba4f39afd..53e59921087 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,13 +32,14 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
+ ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayTchapWithpinningRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-app-gplay-bundle-unsigned
path: |
@@ -76,7 +77,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleGplayTchapWithpinningRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-enterprise-app-gplay-bundle-unsigned
path: |
@@ -104,7 +105,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidTchapWithpinningRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: elementx-app-fdroid-apks-unsigned
path: |
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 23c904c2298..9d7eb8257d5 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -36,7 +36,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
+ uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy
diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml
index d474064e8ed..243e3462849 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -23,7 +23,7 @@ jobs:
- name: Run SAS String script
run: ./tools/sas/import_sas_strings.py
- name: Create Pull Request for SAS Strings
- uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
+ uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f2d65019810..fdca1d44ce8 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -63,7 +63,7 @@ jobs:
- name: 🚫 Upload kover failed coverage reports
if: failure()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: kover-error-report
path: |
@@ -75,7 +75,7 @@ jobs:
- name: 🚫 Upload test results on error
if: failure()
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v6
with:
name: tests-and-screenshot-tests-results
path: |
@@ -85,7 +85,7 @@ jobs:
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
# Skip in forks
if: ${{ github.repository == 'element-hq/element-x-android' && ('pull_request' != github.event_name || github.event.pull_request.head.repo.full_name == github.repository) }}
with:
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 3efb2d8dd4c..dbbf81b44bc 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
-
\ No newline at end of file
+
diff --git a/CHANGES.md b/CHANGES.md
index 01a83c0ccf0..164739cb607 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,200 @@
+Changes in Element X v26.01.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Link new device using QrCode - First version by @bmarty in https://github.com/element-hq/element-x-android/pull/5909
+* Voice message: variable play back speed by @bmarty in https://github.com/element-hq/element-x-android/pull/5963
+* Change Room’s Access to/from Space members by @ganfra in https://github.com/element-hq/element-x-android/pull/5979
+* Create spaces by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5982
+### 🙌 Improvements
+* change(room member): make sure we never display name/avatar when member is banned by @ganfra in https://github.com/element-hq/element-x-android/pull/5826
+* Change : room details edit by @ganfra in https://github.com/element-hq/element-x-android/pull/5844
+* Space feature flags by @ganfra in https://github.com/element-hq/element-x-android/pull/5827
+* Update unsaved change dialog by @bmarty in https://github.com/element-hq/element-x-android/pull/5845
+* change(notification): handle invite notification for spaces by @ganfra in https://github.com/element-hq/element-x-android/pull/5854
+* Change : space settings iteration by @ganfra in https://github.com/element-hq/element-x-android/pull/5908
+* Change : add "settings" entry menu by @ganfra in https://github.com/element-hq/element-x-android/pull/5948
+* Changes : iterate again on permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5950
+### 🐛 Bugfixes
+* fix: usersWithRole(Owner) returns creators only if privilegedCreatorRole is true by @ganfra in https://github.com/element-hq/element-x-android/pull/5832
+* Limit composer height dynamically by @bmarty in https://github.com/element-hq/element-x-android/pull/5835
+* Fix work requests for inaccessible sessions being re-scheduled indefinitely by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5849
+* Fix permission setting navigation by @bmarty in https://github.com/element-hq/element-x-android/pull/5877
+* URL-encode deep link path segments and decode them when parsing by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5880
+* Fix crash when calling `Room.predecessorRoom` when the room is destroyed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5894
+* fix: edit moderators not working by @ganfra in https://github.com/element-hq/element-x-android/pull/5906
+* Use the right video preset when sharing videos by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5892
+* Add `threadInfo` field to message like timeline events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5930
+* Fix unverified account after account creation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5914
+* Fix class cast exception by @bmarty in https://github.com/element-hq/element-x-android/pull/5958
+* Fix : iterate on unban permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5959
+* Use `VerificationState.VERIFIED` as soon as it's available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5973
+* Make the notification silent when the message is an outgoing message by @bmarty in https://github.com/element-hq/element-x-android/pull/5961
+* Remove previously used id filtering from `RoomSyncSubscriber` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5985
+* When handling incoming share, reuse existing room screen if possible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6001
+* When a duplicate room list entry is found, report it and remove it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6006
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5860
+* Sync Strings - Adding translations for Croatian by @ElementBot in https://github.com/element-hq/element-x-android/pull/5904
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5946
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5956
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5971
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5994
+### 🧱 Build
+* Restore `no-unused-imports` behaviour for `ktlintFormat` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5847
+* Fix: use the right `BuildTimeConfig` field for the SDK DSN by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5856
+* Add a way to configure value of useLegacyPackaging by @bmarty in https://github.com/element-hq/element-x-android/pull/5862
+* Improve proguard config to keep the names in the classes in our packages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5882
+* Fix crash when changing the push provider in nightlies by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5951
+### Dependency upgrades
+* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5846
+* fix(deps): update metro to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5833
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5831
+* chore(deps): update plugin sonarqube to v7.2.0.6526 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5851
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5855
+* fix(deps): update dependency io.sentry:sentry-android to v8.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5853
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5852
+* Update dependency io.mockk:mockk to v1.14.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5866
+* Update metro to v0.8.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5867
+* Update peter-evans/create-pull-request action to v7.0.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5865
+* Update camera to v1.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5857
+* fix(deps): update showkase to v1.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5868
+* chore(deps): update codecov/codecov-action action to v5.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5874
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5876
+* fix(deps): update dependency net.zetetic:sqlcipher-android to v4.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5872
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5881
+* Update android.gradle.plugin to v8.13.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5887
+* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5875
+* Update Compose BOM to version 2025.12.00. by @bmarty in https://github.com/element-hq/element-x-android/pull/5179
+* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.3 by @bmarty in https://github.com/element-hq/element-x-android/pull/5913
+* fix(deps): update lifecycle to v2.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5240
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5911
+* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5417
+* fix(deps): update activity to v1.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5770
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5912
+* fix(deps): update dependency io.sentry:sentry-android to v8.29.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5918
+* fix(deps): update dependency com.google.firebase:firebase-bom to v34.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5915
+* fix(deps): update haze to v1.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5712
+* chore(deps): update peter-evans/create-pull-request action to v8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5878
+* fix(deps): update dependency com.posthog:posthog-android to v3.27.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5871
+* chore(deps): update plugin sonarqube to v7.2.1.6560 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5905
+* fix(deps): update metro to v0.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5920
+* fix(deps): update activity to v1.12.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5924
+* Update plugin sonarqube to v7.2.2.6593 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5927
+* fix(deps): update media3 to v1.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5931
+* fix(deps): update metro to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5940
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5939
+* fix(deps): update dependency com.google.zxing:core to v3.5.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5935
+* Upgrade robolectric to version 4.16 by @bmarty in https://github.com/element-hq/element-x-android/pull/5923
+* fix(deps): update dependency androidx.webkit:webkit to v1.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5925
+* chore(deps): update github artifact actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5932
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5883
+* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5916
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5943
+* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5917
+* fix(deps): update dependency com.posthog:posthog-android to v3.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5941
+* fix(deps): update wysiwyg to v2.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5921
+* fix(deps): update roborazzi to v1.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5962
+* fix(deps): update roborazzi to v1.54.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5970
+* fix(deps): update dependency org.unifiedpush.android:connector to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5972
+* fix(deps): update metro to v0.9.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5967
+* Upgrade compose to 2025.12.01 by @bmarty in https://github.com/element-hq/element-x-android/pull/5969
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5977
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5986
+* fix(deps): update roborazzi to v1.56.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5987
+* fix(deps): update dependency com.posthog:posthog-android to v3.28.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5988
+* fix(deps): update metro to v0.9.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5991
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5999
+### Others
+* Enable Sentry in the SDK and allow bridging spans by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5808
+* Add alert to encrypted rooms with visible history (Android). by @kaylendog in https://github.com/element-hq/element-x-android/pull/5709
+* Add accessibility to the "sending" picto. by @bmarty in https://github.com/element-hq/element-x-android/pull/5869
+* Add SDK database vacuuming operations by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5858
+* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.2 by @bmarty in https://github.com/element-hq/element-x-android/pull/5897
+* RoomSummary: move the icon related to the last message state on start of the message. by @bmarty in https://github.com/element-hq/element-x-android/pull/5888
+* Qr code scanner cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/5891
+* Design : update user rows by @ganfra in https://github.com/element-hq/element-x-android/pull/5900
+* misc : rework power levels apis by @ganfra in https://github.com/element-hq/element-x-android/pull/5879
+* Fix preview name by @bmarty in https://github.com/element-hq/element-x-android/pull/5919
+* Allow uploading extra data to Sentry when analytics is enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5910
+* Show history visibility banner strictly for `shared` rooms instead of `invited`. by @kaylendog in https://github.com/element-hq/element-x-android/pull/5936
+* Simplify the copy of the history visibility settings by @bmarty in https://github.com/element-hq/element-x-android/pull/5942
+* Use only font from compound by @bmarty in https://github.com/element-hq/element-x-android/pull/5945
+* Cleanup FFI object fixtures. by @bmarty in https://github.com/element-hq/element-x-android/pull/5957
+* Add variable playback speed feature for voice messages by @Medformatik in https://github.com/element-hq/element-x-android/pull/5504
+* Ensure that avatars always have a content description. by @bmarty in https://github.com/element-hq/element-x-android/pull/5968
+* Ensure space feature is enabled by @ganfra in https://github.com/element-hq/element-x-android/pull/5960
+* Adjust metrics to the new specifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5937
+* Use `TextFieldState` for room list search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5975
+* fix(deps): update roborazzi to v1.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5976
+* Iterate on verification screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5981
+* Add preview with a11y details. by @bmarty in https://github.com/element-hq/element-x-android/pull/5984
+* Change the title for `AnalyticsTransactions.coldStart` and `.catchUp` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5998
+* [a11y] voice message improvements by @bmarty in https://github.com/element-hq/element-x-android/pull/5980
+
+## New Contributors
+* @Medformatik made their first contribution in https://github.com/element-hq/element-x-android/pull/5504
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.12.0...v26.01.0
+
+Changes in Element X v25.12.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Room list: enable latest event sorter. by @bmarty in https://github.com/element-hq/element-x-android/pull/5825
+* Add room list indicators about last message by @bmarty in https://github.com/element-hq/element-x-android/pull/5824
+### 🙌 Improvements
+* Change : improve room and space member list by @ganfra in https://github.com/element-hq/element-x-android/pull/5806
+* Change : security and privacy rework by @ganfra in https://github.com/element-hq/element-x-android/pull/5816
+### 🐛 Bugfixes
+* Ensure confirmation dialog is displayed when an admin add other admin to a room by @bmarty in https://github.com/element-hq/element-x-android/pull/5786
+* Edit user profile cancel confirmation by @bmarty in https://github.com/element-hq/element-x-android/pull/5788
+* Fix editing owner by @bmarty in https://github.com/element-hq/element-x-android/pull/5807
+* Uris should take precedence in plain text intents by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5785
+* Fix long voice recording by @bmarty in https://github.com/element-hq/element-x-android/pull/5821
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5792
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5830
+### 🧱 Build
+* Use regex to check forbidden terms by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5784
+* Update Gradle Wrapper from 8.14.3 to 9.2.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/5751
+### Dependency upgrades
+* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5769
+* fix(deps): update datastore to v1.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5789
+* chore(deps): update peter-evans/create-pull-request action to v7.0.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5793
+* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.4.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5795
+* fix(deps): update metro to v0.7.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5771
+* chore(deps): update plugin sonarqube to v7.1.0.6387 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5783
+* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5799
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.24 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5796
+* fix(deps): update dependency io.sentry:sentry-android to v8.27.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5803
+* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5801
+* fix(deps): update roborazzi to v1.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5804
+* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5814
+* chore(deps): update actions/checkout action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5805
+* fix(deps): update dependency com.google.testparameterinjector:test-parameter-injector to v1.20 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5800
+* fix(deps): update android.gradle.plugin to v8.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5260
+* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5818
+* fix(deps): update dependencyanalysis to v3.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5819
+* fix(deps): update dependency com.posthog:posthog-android to v3.27.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5834
+* fix(deps): update dependency io.element.android:element-call-embedded to v0.16.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5839
+* Upgrade the Rust SDK to `v25.12.2` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5838
+### Others
+* misc : use newLatestEvent api from sdk by @ganfra in https://github.com/element-hq/element-x-android/pull/5809
+* Inject RoomMemberListDataSource in the presenter constructor. by @bmarty in https://github.com/element-hq/element-x-android/pull/5822
+* Add more performance checks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5767
+* Load `JoinedRoom` in home screen, pass it to the room flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5817
+* Revert "fix(deps): update dependency com.posthog:posthog-android to v3.27.0" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5836
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.3...v25.12.0
+
Changes in Element X v25.11.3
=============================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3dc96b79cfb..d3b3bf730d5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -236,6 +236,10 @@ android {
resources.pickFirsts += setOf(
"META-INF/versions/9/OSGI-INF/MANIFEST.MF",
)
+
+ jniLibs {
+ useLegacyPackaging = project.findProperty("useLegacyPackaging")?.toString()?.toBoolean()
+ }
}
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 96109425fa3..b09ecf69ca4 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -66,7 +66,10 @@
-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo
# Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code
--keep class org.matrix.rustcomponents.sdk.** { *;}
--keep class uniffi.** { *;}
--keep class io.element.android.x.di.** { *; }
--keepnames class io.element.android.x.**
+-keep,allowshrinking class org.matrix.rustcomponents.sdk.** { *;}
+-keep,allowshrinking class uniffi.** { *;}
+-keep,allowshrinking class io.element.android.x.di.** { *; }
+-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; }
+
+# Keep Metro classes
+-keep,allowshrinking class dev.zacsweers.metro.** { *; }
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
index 6e157f6ac1a..6768d0d8db1 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
@@ -17,6 +17,7 @@ import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
+import io.element.android.libraries.di.identifiers.SentrySdkDsn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.platform.InitPlatformService
import io.element.android.libraries.matrix.api.tracing.TracingService
@@ -48,4 +49,6 @@ interface AppBindings {
fun featureFlagService(): FeatureFlagService
fun buildMeta(): BuildMeta
+
+ fun sentrySdkDsn(): SentrySdkDsn?
}
diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
index 0eea5123b40..2a844b1331d 100644
--- a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
+++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt
@@ -38,6 +38,7 @@ class PlatformInitializer : Initializer {
logLevel = logLevel,
extraTargets = listOf(ELEMENT_X_TARGET),
traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() },
+ sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() },
)
bugReporter.setCurrentTracingLogLevel(logLevel.name)
platformService.init(tracingConfiguration)
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index a92d7b2ef91..a171646bad1 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -15,6 +15,7 @@
+
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
index 0bc1304fcdf..aeb9f3a6d48 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt
@@ -13,5 +13,8 @@ object LearnMoreConfig {
const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
+
+ // TCHAP - Add FAQ_URL in Preferences
const val FAQ_URL: String = "https://aide.tchap.numerique.gouv.fr"
+ const val HISTORY_VISIBLE_URL: String = "https://element.io/en/help#e2ee-history-sharing"
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
index 1f6609ecc33..38dcd67c9e2 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt
@@ -25,4 +25,9 @@ object RageshakeConfig {
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
*/
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
+
+ /**
+ * The maximum size of a single log file.
+ */
+ const val MAX_LOG_CONTENT_SIZE = 100 * 1024 * 1024L
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
index 539b67825d5..d4fe7d1fc51 100644
--- a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt
@@ -17,19 +17,19 @@ object TimelineConfig {
* Event types that will be filtered out from the timeline (i.e. not displayed).
*/
val excludedEvents = listOf(
- StateEventType.CALL_MEMBER,
- StateEventType.ROOM_ALIASES,
- StateEventType.ROOM_CANONICAL_ALIAS,
- StateEventType.ROOM_GUEST_ACCESS,
- StateEventType.ROOM_HISTORY_VISIBILITY,
- StateEventType.ROOM_JOIN_RULES,
- StateEventType.ROOM_POWER_LEVELS,
- StateEventType.ROOM_SERVER_ACL,
- StateEventType.ROOM_TOMBSTONE,
- StateEventType.SPACE_CHILD,
- StateEventType.SPACE_PARENT,
- StateEventType.POLICY_RULE_ROOM,
- StateEventType.POLICY_RULE_SERVER,
- StateEventType.POLICY_RULE_USER,
+ StateEventType.CallMember,
+ StateEventType.RoomAliases,
+ StateEventType.RoomCanonicalAlias,
+ StateEventType.RoomGuestAccess,
+ StateEventType.RoomHistoryVisibility,
+ StateEventType.RoomJoinRules,
+ StateEventType.RoomPowerLevels,
+ StateEventType.RoomServerAcl,
+ StateEventType.RoomTombstone,
+ StateEventType.SpaceChild,
+ StateEventType.SpaceParent,
+ StateEventType.PolicyRuleRoom,
+ StateEventType.PolicyRuleServer,
+ StateEventType.PolicyRuleUser,
)
}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index aa0bc047722..8bc821f9333 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -48,6 +48,7 @@ dependencies {
implementation(projects.features.announcement.api)
implementation(projects.features.ftue.api)
+ implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api)
implementation(projects.services.apperror.impl)
@@ -62,6 +63,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.forward.test)
+ testImplementation(projects.features.messages.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.impl)
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index 38dc39ee83a..21bdd99a470 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -47,12 +47,14 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark
+import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
@@ -123,6 +125,7 @@ class LoggedInFlowNode(
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
+ private val linkNewDeviceEntryPoint: LinkNewDeviceEntryPoint,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
@@ -142,6 +145,7 @@ class LoggedInFlowNode(
snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
+ private val createRoomEntryPoint: CreateRoomEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -285,6 +289,9 @@ class LoggedInFlowNode(
@Parcelize
data object CreateRoom : NavTarget
+ @Parcelize
+ data object CreateSpace : NavTarget
+
@Parcelize
data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
@@ -293,6 +300,9 @@ class LoggedInFlowNode(
@Parcelize
data object Ftue : NavTarget
+ @Parcelize
+ data object LinkNewDevice : NavTarget
+
@Parcelize
data object RoomDirectory : NavTarget
@@ -333,6 +343,10 @@ class LoggedInFlowNode(
backstack.push(NavTarget.CreateRoom)
}
+ override fun navigateToCreateSpace() {
+ backstack.push(NavTarget.CreateSpace)
+ }
+
override fun navigateToSetUpRecovery() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
}
@@ -419,6 +433,10 @@ class LoggedInFlowNode(
callback.navigateToAddAccount()
}
+ override fun navigateToLinkNewDevice() {
+ backstack.push(NavTarget.LinkNewDevice)
+ }
+
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
@@ -460,6 +478,14 @@ class LoggedInFlowNode(
callback = callback,
)
}
+ is NavTarget.CreateSpace -> {
+ val callback = object : CreateRoomEntryPoint.Callback {
+ override fun onRoomCreated(roomId: RoomId) {
+ backstack.replace(NavTarget.Room(roomIdOrAlias = RoomIdOrAlias.Id(roomId), serverNames = emptyList()))
+ }
+ }
+ createRoomEntryPoint.createNode(isSpace = true, parentNode = this, buildContext = buildContext, callback = callback)
+ }
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode(
parentNode = this,
@@ -475,6 +501,14 @@ class LoggedInFlowNode(
NavTarget.Ftue -> {
ftueEntryPoint.createNode(this, buildContext)
}
+ NavTarget.LinkNewDevice -> {
+ val callback = object : LinkNewDeviceEntryPoint.Callback {
+ override fun onDone() {
+ backstack.pop()
+ }
+ }
+ linkNewDeviceEntryPoint.createNode(this, buildContext, callback)
+ }
NavTarget.RoomDirectory -> {
roomDirectoryEntryPoint.createNode(
parentNode = this,
@@ -499,9 +533,18 @@ class LoggedInFlowNode(
params = ShareEntryPoint.Params(intent = navTarget.intent),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List) {
+ // Remove the incoming share screen
backstack.pop()
+
+ // Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
- backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
+ sessionCoroutineScope.launch {
+ // Wait until the incoming share screen is removed
+ backstack.elements.first { it.lastOrNull()?.key?.navTarget !is NavTarget.IncomingShare }
+
+ // Then attach the room
+ attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
+ }
}
}
},
@@ -627,7 +670,21 @@ private class AttachRoomOperation(
operation = this
)
} else {
- Push(roomTarget).invoke(elements)
+ val existingRoomElement = elements.find {
+ val roomNavTarget = it.key.navTarget as? LoggedInFlowNode.NavTarget.Room
+ roomNavTarget?.roomIdOrAlias == roomTarget.roomIdOrAlias
+ }
+ if (existingRoomElement != null) {
+ elements.mapNotNull { element ->
+ if (element == existingRoomElement) {
+ null
+ } else {
+ element.transitionTo(STASHED, this)
+ }
+ } + existingRoomElement.transitionTo(ACTIVE, this)
+ } else {
+ Push(roomTarget).invoke(elements)
+ }
}
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
index 46879463677..1ec1678d71d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -320,7 +320,7 @@ class RootFlowNode(
is ResolvedIntent.Navigation -> {
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
- analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)
+ analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage)
}
navigateTo(resolvedIntent.deeplinkData)
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
index 4af64cba34c..c6a031921f7 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt
@@ -14,10 +14,13 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -36,6 +39,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
class MatrixSessionCache(
private val authenticationService: MatrixAuthenticationService,
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
+ private val analyticsService: AnalyticsService,
) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap()
private val restoreMutex = Mutex()
@@ -100,6 +104,11 @@ class MatrixSessionCache(
Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
+ // Add the current homeserver (hashed) to the extra info
+ // This may not play well with multiple sessions, but it should work for now
+ analyticsService.addIndexableData(AnalyticsUserData.HOMESERVER, matrixClient.userIdServerName().hash())
+
+ // Add the new client to the in-memory cache
onNewMatrixClient(matrixClient)
}
.onFailure {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
index 53ce50a7889..9b1bbd1b816 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
+import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
@@ -77,7 +78,7 @@ class SyncOrchestrator(
// Wait until the sync service is not idle, either it will be running or in error/offline state
val firstState = syncService.syncState.first { it != SyncState.Idle }
- transaction.setData("first_sync_state", firstState.name)
+ transaction.putIndexableData(AnalyticsUserData.FIRST_SYNC_STATE, firstState.name)
}
observeStates()
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt
index cb78760a0f2..371b0676375 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt
@@ -10,8 +10,10 @@ package io.element.android.appnav.di
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
+import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
interface TimelineBindings {
val timelineProvider: TimelineProvider
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
+ val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
index 1bf3516900d..6446f754b0d 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt
@@ -49,7 +49,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
-import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
+import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.SharingStarted
@@ -128,7 +128,7 @@ class RoomFlowNode(
override fun onBuilt() {
super.onBuilt()
- val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
+ val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId()
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
index 5a6ef9133bf..05ebab2b102 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt
@@ -8,7 +8,9 @@
package io.element.android.appnav.room.joined
+import android.app.Activity
import android.os.Parcelable
+import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
@@ -96,6 +98,11 @@ class JoinedRoomLoadedFlowNode(
private val callback: Callback = callback()
override val graph = roomGraphFactory.create(inputs.room)
+ private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher
+
+ // This is an ugly hack to check activity recreation
+ private var currentActivity: Activity? = null
+
init {
lifecycle.subscribe(
onCreate = {
@@ -104,6 +111,7 @@ class JoinedRoomLoadedFlowNode(
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
+ sendMessageWatcher?.start()
fetchRoomMembers()
trackVisitedRoom()
},
@@ -115,8 +123,13 @@ class JoinedRoomLoadedFlowNode(
},
onDestroy = {
Timber.v("OnDestroy")
- activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
- inputs.room.destroy()
+ sendMessageWatcher?.stop()
+ // If we're just going through an activity recreation there's no need to destroy the Room object
+ // Destroying it would actually cause an issue where its methods can no longer be called
+ if (currentActivity?.isChangingConfigurations != true) {
+ activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
+ inputs.room.destroy()
+ }
appNavigationStateService.onLeavingRoom(id)
}
)
@@ -289,6 +302,8 @@ class JoinedRoomLoadedFlowNode(
@Composable
override fun View(modifier: Modifier) {
+ currentActivity = LocalActivity.current
+
BackstackView()
}
}
diff --git a/appnav/src/main/res/values-hr/translations.xml b/appnav/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..f75fa613f29
--- /dev/null
+++ b/appnav/src/main/res/values-hr/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Odjava i nadogradnja"
+ "%1$s više ne podržava stari protokol. Odjavite se i ponovno prijavite kako biste se nastavili služiti aplikacijom."
+ "Vaš matični poslužitelj više ne podržava stari protokol. Odjavite se i ponovno prijavite kako biste se nastavili služiti aplikacijom."
+
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
index 6cd7df025be..8d514a2c0fe 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
@@ -19,22 +19,29 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomGraphFactory
+import io.element.android.appnav.di.TimelineBindings
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
+import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
+import io.element.android.features.messages.test.pinned.FakePinnedEventsTimelineProvider
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
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
+import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
+import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher
import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMessageWatcher
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
@@ -72,9 +79,20 @@ class JoinedRoomLoadedFlowNodeTest {
}
}
- private class FakeRoomGraphFactory : RoomGraphFactory {
+ private class FakeRoomGraphFactory(
+ private val timelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
+ private val pinnedEventsTimelineProvider: FakePinnedEventsTimelineProvider = FakePinnedEventsTimelineProvider(),
+ private val analyticsSendMessageWatcher: FakeAnalyticsSendMessageWatcher = FakeAnalyticsSendMessageWatcher(),
+ ) : RoomGraphFactory {
override fun create(room: JoinedRoom): Any {
- return Unit
+ return object : TimelineBindings {
+ override val timelineProvider: TimelineProvider
+ get() = this@FakeRoomGraphFactory.timelineProvider
+ override val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
+ get() = this@FakeRoomGraphFactory.pinnedEventsTimelineProvider
+ override val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher
+ get() = this@FakeRoomGraphFactory.analyticsSendMessageWatcher
+ }
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
index 56c20f7a1df..36fa471dd04 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt
@@ -19,29 +19,31 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
+@OptIn(ExperimentalCoroutinesApi::class)
class MatrixSessionCacheTest {
@Test
fun `test getOrNull`() = runTest {
- val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val matrixSessionCache = createMatrixSessionCache()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test getSyncOrchestratorOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
// With no matrix client there is no sync orchestrator
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull()
// But as soon as we receive a client, we can get the sync orchestrator
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull()
@@ -50,8 +52,8 @@ class MatrixSessionCacheTest {
@Test
fun `test getOrRestore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
@@ -63,8 +65,8 @@ class MatrixSessionCacheTest {
@Test
fun `test remove`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
@@ -76,8 +78,8 @@ class MatrixSessionCacheTest {
@Test
fun `test remove all`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
@@ -89,8 +91,8 @@ class MatrixSessionCacheTest {
@Test
fun `test save and restore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
- val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService)
+ val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value })
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
matrixSessionCache.getOrRestore(A_SESSION_ID)
val savedStateMap = MutableSavedStateMapImpl { true }
@@ -109,29 +111,45 @@ class MatrixSessionCacheTest {
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
- val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
+ val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
- fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
+
+ runCurrent()
+
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull()
}
- private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
- override fun create(
- syncService: SyncService,
- sessionCoroutineScope: CoroutineScope,
- ): SyncOrchestrator {
- return SyncOrchestrator(
- syncService = syncService,
- sessionCoroutineScope = sessionCoroutineScope,
- appForegroundStateService = FakeAppForegroundStateService(),
- networkMonitor = FakeNetworkMonitor(),
- dispatchers = testCoroutineDispatchers(),
- analyticsService = FakeAnalyticsService(),
- )
+ private fun TestScope.createMatrixSessionCache(
+ authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
+ syncOrchestratorFactory: SyncOrchestrator.Factory = createSyncOrchestratorFactory(),
+ analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
+ ) = MatrixSessionCache(
+ authenticationService = authenticationService,
+ syncOrchestratorFactory = syncOrchestratorFactory,
+ analyticsService = analyticsService,
+ )
+
+ private fun TestScope.createSyncOrchestratorFactory(): SyncOrchestrator.Factory {
+ val dispatchers = testCoroutineDispatchers()
+
+ return object : SyncOrchestrator.Factory {
+ override fun create(
+ syncService: SyncService,
+ sessionCoroutineScope: CoroutineScope,
+ ): SyncOrchestrator {
+ return SyncOrchestrator(
+ syncService = syncService,
+ sessionCoroutineScope = sessionCoroutineScope,
+ appForegroundStateService = FakeAppForegroundStateService(),
+ networkMonitor = FakeNetworkMonitor(),
+ dispatchers = dispatchers,
+ analyticsService = FakeAnalyticsService(),
+ )
+ }
}
}
}
diff --git a/build.gradle.kts b/build.gradle.kts
index d3b68354e26..7a5b71a27a1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.4.28")
+ detektPlugins("io.nlopez.compose.rules:detekt:0.5.3")
detektPlugins(project(":tests:detekt-rules"))
}
diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
index 7ddf80b92ca..8b537a53fe9 100644
--- a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
+++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt
@@ -91,7 +91,7 @@ class ContributesNodeProcessor(
.addAnnotation(Binds::class)
.addAnnotation(IntoMap::class)
.addAnnotation(
- AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
+ AnnotationSpec.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember(
CLASS_PLACEHOLDER,
ClassName.bestGuess(ksClass.qualifiedName!!.asString())
).build()
diff --git a/fastlane/metadata/android/en-US/changelogs/202601000.txt b/fastlane/metadata/android/en-US/changelogs/202601000.txt
new file mode 100644
index 00000000000..98c450dce09
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202601000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: iterated on spaces, improved the room list stability and performance, and a long list of bug fixes.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202601010.txt b/fastlane/metadata/android/en-US/changelogs/202601010.txt
new file mode 100644
index 00000000000..605371b852a
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202601010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: iterated on spaces, improved the room list stability and performance, fix an issue with the cached well-known config.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/features/analytics/api/src/main/res/values-hr/translations.xml b/features/analytics/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..ac1e83c87b9
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Podijelite anonimne podatke o korištenju kako biste nam pomogli u otkrivanju problema."
+ "Možete pročitati sve naše uvjete %1$s ."
+ "ovdje"
+ "Dijeljenje analitičkih podataka"
+
diff --git a/features/analytics/impl/src/main/res/values-hr/translations.xml b/features/analytics/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..b85046c7a9a
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Nećemo bilježiti niti profilirati nikakve osobne podatke"
+ "Podijelite anonimne podatke o korištenju kako biste nam pomogli u otkrivanju problema."
+ "Možete pročitati sve naše uvjete %1$s ."
+ "ovdje"
+ "Ovo možete isključiti u bilo kojem trenutku"
+ "Nećemo dijeliti vaše podatke s trećim stranama"
+ "Pomozite nam poboljšati %1$s"
+
diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt
index d96de7511fa..3fe6ec4456d 100644
--- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt
+++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt
@@ -81,7 +81,7 @@ private fun SpaceAnnouncementHeader(
showBetaLabel = true,
subTitle = stringResource(id = R.string.screen_space_announcement_subtitle),
iconStyle = BigIcon.Style.Default(
- vectorIcon = CompoundIcons.Space(),
+ vectorIcon = CompoundIcons.SpaceSolid(),
usePrimaryTint = true,
),
)
diff --git a/features/announcement/impl/src/main/res/values-hr/translations.xml b/features/announcement/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..e78f29f19a1
--- /dev/null
+++ b/features/announcement/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,11 @@
+
+
+ "Pregledajte prostore koje ste stvorili ili kojima ste se pridružili"
+ "Prihvatite ili odbijte pozivnice za prostore"
+ "Otkrijte sve sobe kojima se možete pridružiti u svojim prostorima"
+ "Pridružite se javnim prostorima"
+ "Napustite sve prostore kojima ste se pridružili"
+ "Uskoro stiže filtriranje i stvaranje prostora te upravljanje njima."
+ "Dobrodošli u beta inačicu prostora! S ovom prvom inačicom možete:"
+ "Predstavljamo prostore"
+
diff --git a/features/announcement/impl/src/main/res/values-ro/translations.xml b/features/announcement/impl/src/main/res/values-ro/translations.xml
index 48fa06fca4e..716f1faeb2a 100644
--- a/features/announcement/impl/src/main/res/values-ro/translations.xml
+++ b/features/announcement/impl/src/main/res/values-ro/translations.xml
@@ -5,7 +5,7 @@
"Descoperiți toate camerele la care vă puteți alătura în spațiile dumneavoastră."
"Alăturați-vă spațiilor publice"
"Părăsiți spațiile la care v-ați alăturat."
- "Crearea și gestionarea spațiilor vor fi disponibile în curând."
+ "Filtrarea, crearea și gestionarea spațiilor vor fi disponibile în curând."
"Bun venit la versiunea beta a Spațiilor! Cu această primă versiune puteți:"
"Vă prezentăm Spații"
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
index 91fcc2593e2..4183e225314 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
@@ -177,8 +177,8 @@ class DefaultActiveCallManager(
suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
Timber.tag(tag).d("Incoming call timed out")
- val previousActiveCall = activeCall.value ?: return
- val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
+ val previousActiveCall = activeCall.value ?: return@withLock
+ val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return@withLock
activeCall.value = null
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after timeout")
@@ -196,11 +196,11 @@ class DefaultActiveCallManager(
Timber.tag(tag).d("Hung up call: $callType")
val currentActiveCall = activeCall.value ?: run {
Timber.tag(tag).w("No active call, ignoring hang up")
- return
+ return@withLock
}
if (currentActiveCall.callType != callType) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
- return
+ return@withLock
}
if (currentActiveCall.callState is CallState.Ringing) {
// Decline the call
diff --git a/features/call/impl/src/main/res/values-hr/translations.xml b/features/call/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..e58dc362fa5
--- /dev/null
+++ b/features/call/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Poziv u tijeku"
+ "Dodirnite za povratak u poziv"
+ "☎️ Poziv u tijeku"
+ "Element Call ne podržava korištenje Bluetooth audiouređaja u ovoj inačici Androida. Odaberite drugi audiouređaj."
+ "Dolazni Element Call"
+
diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
index 1c6a9f04db9..22757aba062 100644
--- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
+++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint {
fun createNode(
+ isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
index 7fea6fc0e50..89dbddd1864 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt
@@ -24,6 +24,7 @@ import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@@ -37,23 +38,29 @@ class CreateRoomFlowNode(
@Assisted plugins: List,
) : BaseFlowNode(
backstack = BackStack(
- initialElement = NavTarget.ConfigureRoom,
+ initialElement = NavTarget.ConfigureRoom(isSpace = plugins.filterIsInstance().first().isSpace),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
+ @Parcelize
+ data class Inputs(
+ val isSpace: Boolean
+ ) : NodeInputs, Parcelable
+
private val callback: CreateRoomEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
- NavTarget.ConfigureRoom -> {
+ is NavTarget.ConfigureRoom -> {
+ val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace)
val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.replace(NavTarget.AddPeople(roomId))
}
}
- createNode(buildContext, plugins = listOf(callback))
+ createNode(buildContext, plugins = listOf(inputs, callback))
}
is NavTarget.AddPeople -> {
val inputs = AddPeopleNode.Inputs(navTarget.roomId)
@@ -74,7 +81,7 @@ class CreateRoomFlowNode(
sealed interface NavTarget : Parcelable {
@Parcelize
- data object ConfigureRoom : NavTarget
+ data class ConfigureRoom(val isSpace: Boolean) : NavTarget
@Parcelize
data class AddPeople(val roomId: RoomId) : NavTarget
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
index 2261d294cfa..63163e7a281 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt
@@ -18,10 +18,12 @@ import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
+ isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
): Node {
- return parentNode.createNode(buildContext, listOf(callback))
+ val inputs = CreateRoomFlowNode.Inputs(isSpace)
+ return parentNode.createNode(buildContext, listOf(inputs, callback))
}
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
index 43ceee3594e..e021a7a0f9e 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt
@@ -8,6 +8,7 @@
package io.element.android.features.createroom.impl.configureroom
+import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
@@ -18,23 +19,35 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class ConfigureRoomNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: ConfigureRoomPresenter,
+ presenterFactory: ConfigureRoomPresenter.Factory,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onCreateRoomSuccess(roomId: RoomId)
}
+ @Parcelize
+ data class Inputs(
+ val isSpace: Boolean,
+ ) : NodeInputs, Parcelable
+
+ private val inputs = inputs()
+
+ private val presenter = presenterFactory.create(inputs.isSpace)
+
init {
lifecycle.subscribe(
onResume = {
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
index 7523ac536d8..4609fe3810b 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt
@@ -19,7 +19,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.net.toUri
-import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedFactory
+import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
@@ -41,7 +43,7 @@ import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEf
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList
@@ -50,8 +52,9 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.jvm.optionals.getOrDefault
-@Inject
+@AssistedInject
class ConfigureRoomPresenter(
+ @Assisted private val isSpace: Boolean,
private val dataStore: CreateRoomConfigStore,
private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider,
@@ -62,13 +65,22 @@ class ConfigureRoomPresenter(
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(isSpace: Boolean): ConfigureRoomPresenter
+ }
+
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
+ init {
+ dataStore.setIsSpace(isSpace)
+ }
+
@Composable
override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
- val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState(CreateRoomConfig())
+ val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState()
val homeserverName = remember { matrixClient.userIdServerName() }
val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
@@ -133,7 +145,7 @@ class ConfigureRoomPresenter(
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
}
@@ -175,7 +187,8 @@ class ConfigureRoomPresenter(
preset = RoomPreset.PUBLIC_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
- roomAliasName = config.roomVisibility.roomAddress()
+ roomAliasName = config.roomVisibility.roomAddress(),
+ isSpace = isSpace,
)
}
is RoomVisibilityState.Private -> {
@@ -190,6 +203,7 @@ class ConfigureRoomPresenter(
preset = RoomPreset.PRIVATE_CHAT,
invite = config.invites.map { it.userId },
avatar = avatarUrl,
+ isSpace = isSpace,
)
}
// TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
index 7f760b46fba..dc78ed7aab0 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt
@@ -78,6 +78,18 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider Unit,
modifier: Modifier = Modifier,
) {
+ val isSpace = state.config.isSpace
val focusManager = LocalFocusManager.current
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
@@ -81,6 +87,7 @@ fun ConfigureRoomView(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
ConfigureRoomToolbar(
+ isSpace = isSpace,
isNextActionEnabled = state.isValid,
onBackClick = onBackClick,
onNextClick = {
@@ -96,9 +103,10 @@ fun ConfigureRoomView(
.imePadding()
.verticalScroll(rememberScrollState())
.consumeWindowInsets(padding),
- verticalArrangement = Arrangement.spacedBy(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomNameWithAvatar(
+ isSpace = isSpace,
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.config.avatarUri,
roomName = state.config.roomName.orEmpty(),
@@ -110,39 +118,37 @@ fun ConfigureRoomView(
topic = state.config.topic.orEmpty(),
onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
- RoomVisibilityOptions(
+
+ RoomVisibilityAndAccessOptions(
selected = when (state.config.roomVisibility) {
is RoomVisibilityState.Private -> RoomVisibilityItem.Private
// TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
// is RoomVisibilityState.PrivateNotEncrypted -> RoomVisibilityItem.PrivateNotEncrypted // TCHAP room type
- is RoomVisibilityState.Public -> RoomVisibilityItem.Public
+ is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) {
+ RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin
+ RoomAccess.Anyone -> RoomVisibilityItem.Public
+ }
},
+ isKnockingEnabled = state.isKnockFeatureEnabled,
onOptionClick = {
focusManager.clearFocus()
state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it))
},
)
- if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) {
- RoomAccessOptions(
- selected = when (state.config.roomVisibility.roomAccess) {
- RoomAccess.Anyone -> RoomAccessItem.Anyone
- RoomAccess.Knocking -> RoomAccessItem.AskToJoin
- },
- onOptionClick = {
- focusManager.clearFocus()
- state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it))
- },
- )
- RoomAddressField(
- modifier = Modifier.padding(horizontal = 16.dp),
- address = state.config.roomVisibility.roomAddress.value,
- homeserverName = state.homeserverName,
- addressValidity = state.roomAddressValidity,
- onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
- label = stringResource(R.string.screen_create_room_room_address_section_title),
- supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
- )
- Spacer(Modifier)
+
+ if (state.config.roomVisibility !is RoomVisibilityState.Private) {
+ Column {
+ ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title))
+ RoomAddressField(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(),
+ homeserverName = state.homeserverName,
+ addressValidity = state.roomAddressValidity,
+ onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) },
+ label = null,
+ supportingText = stringResource(R.string.screen_create_room_room_address_section_footer),
+ )
+ }
}
}
}
@@ -158,11 +164,11 @@ fun ConfigureRoomView(
async = state.createRoomAction,
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
- progressText = stringResource(CommonStrings.common_creating_room),
+ progressText = stringResource(if (isSpace) CommonStrings.common_creating_space else CommonStrings.common_creating_room),
)
},
onSuccess = { onCreateRoomSuccess(it) },
- errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
+ errorMessage = { stringResource(if (isSpace) R.string.screen_create_room_error_creating_space else R.string.screen_create_room_error_creating_room) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
)
@@ -175,12 +181,13 @@ fun ConfigureRoomView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ConfigureRoomToolbar(
+ isSpace: Boolean,
isNextActionEnabled: Boolean,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
) {
TopAppBar(
- titleStr = stringResource(R.string.screen_create_room_title),
+ titleStr = stringResource(if (isSpace) R.string.screen_create_room_new_space_title else R.string.screen_create_room_new_room_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
@@ -194,6 +201,7 @@ private fun ConfigureRoomToolbar(
@Composable
private fun RoomNameWithAvatar(
+ isSpace: Boolean,
avatarUri: String?,
roomName: String,
onAvatarClick: () -> Unit,
@@ -205,25 +213,33 @@ private fun RoomNameWithAvatar(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- val a11yAvatar = stringResource(CommonStrings.a11y_room_avatar)
- UnsavedAvatar(
- avatarUri = avatarUri,
- avatarSize = AvatarSize.EditRoomDetails,
- avatarType = AvatarType.Room(),
- modifier = Modifier
- .clickable(
- onClick = onAvatarClick,
- onClickLabel = stringResource(CommonStrings.action_open_context_menu),
- )
- .clearAndSetSemantics {
- contentDescription = a11yAvatar
- },
- )
+ Box(
+ modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ val avatarState = remember(avatarUri) {
+ if (avatarUri != null) {
+ AvatarPickerState.Selected(
+ avatarData = AvatarData(id = "#", name = null, url = avatarUri, size = AvatarSize.EditRoomDetails),
+ type = if (isSpace) AvatarType.Space() else AvatarType.Room(),
+ )
+ } else {
+ val containerSize = 48.dp
+ val padding = PaddingValues((AvatarSize.EditRoomDetails.dp - containerSize) / 2)
+ AvatarPickerState.Pick(buttonSize = 48.dp, iconSize = 24.dp, externalPadding = padding)
+ }
+ }
+ AvatarPickerView(
+ state = avatarState,
+ onClick = onAvatarClick,
+ )
+ }
TextField(
- label = stringResource(R.string.screen_create_room_room_name_label),
+ modifier = Modifier.padding(bottom = 18.dp),
+ label = stringResource(CommonStrings.common_name),
value = roomName,
- placeholder = stringResource(CommonStrings.common_room_name_placeholder),
+ placeholder = stringResource(R.string.screen_create_room_name_placeholder),
singleLine = true,
onValueChange = onChangeRoomName,
)
@@ -242,7 +258,7 @@ private fun RoomTopic(
value = topic,
onValueChange = onTopicChange,
maxLines = 3,
- supportingText = stringResource(CommonStrings.common_topic_placeholder),
+ placeholder = stringResource(R.string.screen_create_room_topic_placeholder),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
),
@@ -258,40 +274,65 @@ private fun ConfigureRoomOptions(
Column(
modifier = modifier.selectableGroup()
) {
- Text(
- text = title,
- style = ElementTheme.typography.fontBodyLgMedium,
- color = ElementTheme.colors.textPrimary,
- modifier = Modifier.padding(horizontal = 16.dp),
- )
+ ListSectionHeader(title = title)
content()
}
}
@Composable
-private fun RoomVisibilityOptions(
+private fun RoomVisibilityAndAccessOptions(
selected: RoomVisibilityItem,
+ isKnockingEnabled: Boolean,
onOptionClick: (RoomVisibilityItem) -> Unit,
modifier: Modifier = Modifier,
) {
ConfigureRoomOptions(
+ // TCHAP string update
+// title = stringResource(R.string.screen_create_room_room_access_section_title),
title = stringResource(R.string.tchap_screen_create_room_room_access_encryption_section_title),
modifier = modifier,
) {
RoomVisibilityItem.entries.forEach { item ->
+ if (item == RoomVisibilityItem.AskToJoin && !isKnockingEnabled) {
+ return@forEach
+ }
+
val isSelected = item == selected
ListItem(
leadingContent = ListItemContent.Custom {
RoundedIconAtom(
size = RoundedIconAtomSize.Big,
- resourceId = item.icon,
+ resourceId = when (item) {
+ RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public
+ RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add
+ RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock
+ // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
+// RoomVisibilityItem.PrivateNotEncrypted -> CompoundDrawables.ic_compound_lock_off
+ },
tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary,
+ backgroundTint = Color.Transparent,
)
},
- headlineContent = { Text(text = stringResource(item.title)) },
+ headlineContent = {
+ val title = when (item) {
+ RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title)
+ RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title)
+ RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_title)
+ // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
+// RoomVisibilityItem.PrivateNotEncrypted -> stringResource(R.string.screen_create_room_private_option_title)
+ }
+ Text(text = title)
+ },
supportingContent = {
- val content = stringResource(id = item.description)
- Text(text = content)
+ // TODO handle description of items in a certain space/org
+ val description = when (item) {
+ RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description)
+ RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description)
+ RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description)
+ // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
+// RoomVisibilityItem.PrivateNotEncrypted -> stringResource(R.string.tchap_screen_create_room_private_not_encrypted_option_description)
+ }
+ Text(text = description)
},
trailingContent = ListItemContent.RadioButton(selected = isSelected),
onClick = { onOptionClick(item) },
@@ -300,27 +341,6 @@ private fun RoomVisibilityOptions(
}
}
-@Composable
-private fun RoomAccessOptions(
- selected: RoomAccessItem,
- onOptionClick: (RoomAccessItem) -> Unit,
- modifier: Modifier = Modifier,
-) {
- ConfigureRoomOptions(
- title = stringResource(R.string.screen_create_room_room_access_section_header),
- modifier = modifier,
- ) {
- RoomAccessItem.entries.forEach { item ->
- ListItem(
- headlineContent = { Text(text = stringResource(item.title)) },
- supportingContent = { Text(text = stringResource(item.description)) },
- trailingContent = ListItemContent.RadioButton(selected = item == selected),
- onClick = { onOptionClick(item) },
- )
- }
- }
-}
-
@PreviewWithLargeHeight
@Composable
internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) =
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
index b9d05a144fe..8355047ddf8 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt
@@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class CreateRoomConfig(
+ val isSpace: Boolean = false,
val roomName: String? = null,
val topic: String? = null,
val avatarUri: String? = null,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
index c6b4e3271fa..1394bbe6866 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt
@@ -74,11 +74,11 @@ class CreateRoomConfigStore(
RoomVisibilityItem.Private -> RoomVisibilityState.Private
// TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
// RoomVisibilityItem.PrivateNotEncrypted -> RoomVisibilityState.PrivateNotEncrypted // TCHAP room type
- RoomVisibilityItem.Public -> {
+ RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> {
val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty())
RoomVisibilityState.Public(
roomAddress = RoomAddress.AutoFilled(roomAliasName),
- roomAccess = RoomAccess.Anyone,
+ roomAccess = if (visibility == RoomVisibilityItem.AskToJoin) RoomAccess.Knocking else RoomAccess.Anyone,
)
}
}
@@ -116,6 +116,12 @@ class CreateRoomConfigStore(
}
}
+ fun setIsSpace(isSpace: Boolean) {
+ createRoomConfigFlow.getAndUpdate { config ->
+ config.copy(isSpace = isSpace)
+ }
+ }
+
fun clearCachedData() {
cachedAvatarUri = null
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
index 2d37be91036..9d140b952cb 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt
@@ -8,19 +8,7 @@
package io.element.android.features.createroom.impl.configureroom
-import androidx.annotation.StringRes
-import io.element.android.features.createroom.impl.R
-
-enum class RoomAccessItem(
- @StringRes val title: Int,
- @StringRes val description: Int
-) {
- Anyone(
- title = R.string.screen_create_room_room_access_section_anyone_option_title,
- description = R.string.screen_create_room_room_access_section_anyone_option_description,
- ),
- AskToJoin(
- title = R.string.screen_create_room_room_access_section_knocking_option_title,
- description = R.string.screen_create_room_room_access_section_knocking_option_description,
- ),
+enum class RoomAccessItem {
+ Anyone,
+ AskToJoin,
}
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
index fc0eb96f9d1..0f50c8c7002 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt
@@ -8,32 +8,10 @@
package io.element.android.features.createroom.impl.configureroom
-import androidx.annotation.DrawableRes
-import androidx.annotation.StringRes
-import io.element.android.features.createroom.impl.R
-import io.element.android.libraries.designsystem.icons.CompoundDrawables
-
-enum class RoomVisibilityItem(
- @DrawableRes val icon: Int,
- @StringRes val title: Int,
- @StringRes val description: Int
-) {
- // TCHAP room type
- Private(
- icon = CompoundDrawables.ic_compound_lock_solid,
- title = R.string.tchap_screen_create_room_private_encrypted_option_title,
- description = R.string.tchap_screen_create_room_private_encrypted_option_description,
- ),
-
+enum class RoomVisibilityItem {
+ Public,
+ AskToJoin,
+ Private,
// TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation
-// PrivateNotEncrypted(
-// icon = CompoundDrawables.ic_compound_lock_off,
-// title = R.string.screen_create_room_private_option_title,
-// description = R.string.tchap_screen_create_room_private_not_encrypted_option_description,
-// ),
- Public(
- icon = CompoundDrawables.ic_compound_public,
- title = R.string.screen_create_room_public_option_title,
- description = R.string.tchap_screen_create_room_public_option_description,
- )
+// PrivateNotEncrypted
}
diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml
index f5d6a234e2d..4cc6aee8227 100644
--- a/features/createroom/impl/src/main/res/values-be/translations.xml
+++ b/features/createroom/impl/src/main/res/values-be/translations.xml
@@ -4,14 +4,10 @@
"Запрасіць карыстальнікаў"
"Пры стварэнні пакоя адбылася памылка"
"Толькі запрошаныя людзі могуць атрымаць доступ да гэтага пакоя. Усе паведамленні абаронены end-to-end шыфраваннем."
- "Прыватны пакой"
"Любы можа знайсці гэты пакой.
Вы можаце змяніць гэта ў любы час у наладах пакоя."
- "Публічны пакой"
- "Хто заўгодна"
- "Доступ у пакой"
+ "Публічны пакой (для ўсіх)"
"Папрасіце далучыцца"
- "Назва пакоя"
- "Стварыце пакой"
+ "Хто заўгодна"
"Тэма (неабавязкова)"
diff --git a/features/createroom/impl/src/main/res/values-bg/translations.xml b/features/createroom/impl/src/main/res/values-bg/translations.xml
index 249058b7af8..4c6bfed0e47 100644
--- a/features/createroom/impl/src/main/res/values-bg/translations.xml
+++ b/features/createroom/impl/src/main/res/values-bg/translations.xml
@@ -4,15 +4,12 @@
"Поканване на хора"
"Възникна грешка при създаването на стаята"
"Само поканени хора имат достъп до тази стая. Всички съобщения са шифровани от край до край."
- "Частна стая"
"Всеки може да намери тази стая.
Можете да промените това по всяко време в настройките на стаята."
- "Общодостъпна стая"
- "Всеки може да се присъедини към тази стая"
- "Всеки"
+ "Публична стая (всеки)"
+ "Всеки може да се присъедини към тази стая"
+ "Всеки"
"За да бъде тази стая видима в директорията на общодостъпните стаи, ще ви е необходим адрес на стаята."
- "Име на стаята"
"Видимост на стаята"
- "Създаване на стая"
"Тема за разговор (незадължително)"
diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml
index e19cfbcf917..39f9d573e02 100644
--- a/features/createroom/impl/src/main/res/values-cs/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cs/translations.xml
@@ -4,19 +4,15 @@
"Pozvat přátele"
"Při vytváření místnosti došlo k chybě"
"Do této místnosti mají přístup pouze pozvaní lidé. Všechny zprávy jsou koncově šifrovány."
- "Soukromá místnost"
"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."
"Veřejná místnost"
- "Do této místnosti může vstoupit kdokoli"
- "Kdokoliv"
- "Přístup do místnosti"
"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout"
"Požádat o připojení"
+ "Do této místnosti může vstoupit kdokoli"
+ "Kdokoliv"
"Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti."
"Adresa místnosti"
- "Název místnosti"
"Viditelnost místnosti"
- "Vytvořit místnost"
"Téma (nepovinné)"
diff --git a/features/createroom/impl/src/main/res/values-cy/translations.xml b/features/createroom/impl/src/main/res/values-cy/translations.xml
index 52168014bf8..9ac9e5e3bcd 100644
--- a/features/createroom/impl/src/main/res/values-cy/translations.xml
+++ b/features/createroom/impl/src/main/res/values-cy/translations.xml
@@ -4,19 +4,15 @@
"Gwahodd pobl"
"Bu gwall wrth greu\'r ystafell"
"Dim ond pobl wahoddwyd all gael mynediad i\'r ystafell hon. Mae pob neges wedi\'i hamgryptio o\'r dechrau i\'r diwedd."
- "Ystafell breifat"
"Gall unrhyw un ddod o hyd i\'r ystafell hon.
Gallwch newid hyn unrhyw bryd yng ngosodiadau ystafell."
"Ystafell gyhoeddus"
- "Gall unrhyw un ymuno â\'r ystafell hon"
- "Unrhyw un"
- "Mynediad i\'r Ystafell"
"Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais"
"Gofyn i gael ymuno"
+ "Gall unrhyw un ymuno â\'r ystafell hon"
+ "Unrhyw un"
"Er mwyn i\'r ystafell hon fod yn weladwy yn y cyfeiriadur ystafelloedd cyhoeddus, bydd angen cyfeiriad ystafell arnoch."
"Cyfeiriad yr ystafell"
- "Enw\'r ystafell"
"Gwelededd yr ystafell"
- "Creu ystafell"
"Pwnc (dewisol)"
diff --git a/features/createroom/impl/src/main/res/values-da/translations.xml b/features/createroom/impl/src/main/res/values-da/translations.xml
index 422aae09bd8..13d8b01b627 100644
--- a/features/createroom/impl/src/main/res/values-da/translations.xml
+++ b/features/createroom/impl/src/main/res/values-da/translations.xml
@@ -4,18 +4,14 @@
"Invitér andre"
"Der opstod en fejl ved oprettelsen af rummet"
"Kun inviterede personer kan få adgang til dette rum. Alle meddelelser er ende-til-ende krypteret."
- "Privat rum"
"Alle kan finde dette rum.
Du kan ændre dette når som helst i rummets indstillinger."
- "Offentligt rum"
- "Alle kan deltage i dette rum"
- "Enhver"
- "Adgang til rummet"
"Alle kan bede om at deltage i rummet, men en administrator eller en moderator skal acceptere anmodningen"
"Spørg om at deltage"
+ "Alle kan deltage i dette rum"
+ "Enhver"
"Hvis dette rum skal være synligt i det offentlige register, skal du bruge en rum-adresse."
- "Navn på rum"
+ "Rummets adresse"
"Rummets synlighed"
- "Opret et rum"
"Emne (valgfrit)"
diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml
index 9c48001c92b..55d7a0b66e9 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -3,20 +3,26 @@
"Neuer Chat"
"Nutzer einladen"
"Beim Erstellen des Chats ist ein Fehler aufgetreten"
- "Nur eingeladene Personen haben Zutritt zu diesem Chat. Alle Nachrichten sind Ende-zu-Ende verschlüsselt."
- "Privater Chat"
+ "Der Space konnte wegen eines unbekannten Fehlers nicht erstellt werden. Versuch\' es später nochmal."
+ "Name hinzufügen…"
+ "Neuer Chat"
+ "Neuer Space"
+ "Nur eingeladene Personen haben Zutritt zu diesem Chat."
+ "Privat"
"Jeder kann diesen Chat finden.
Du kannst dies jederzeit in den Einstellungen des Chats ändern."
- "Öffentlicher Chat"
- "Jeder darf diesem Chat beitreten"
- "Jeder"
- "Chat Zugang"
+ "Jeder kann beitreten."
+ "Öffentlicher Chatroom"
"Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren."
- "Beitritt beantragen"
- "Du benötigst eine Chat-Adresse, damit dieser Chat im öffentlichen Verzeichnis sichtbar ist."
- "Chatroom Adresse"
- "Chat-Name"
+ "Anfrage zum Beitritt zulassen"
+ "Nur eingeladene Personen können beitreten."
+ "Privat"
+ "Jeder darf diesem Chat beitreten."
+ "Jeder"
+ "Wer hat Zugang"
+ "Du benötigst eine Adresse, um diesen Chat im öffentlichen Verzeichnis sichtbar zu machen."
+ "Adresse"
" Sichtbarkeit des Chats"
- "Chat erstellen"
"Thema (optional)"
+ "Beschreibung hinzufügen…"
diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml
index 37ccd49d0ed..3b1c94c4430 100644
--- a/features/createroom/impl/src/main/res/values-el/translations.xml
+++ b/features/createroom/impl/src/main/res/values-el/translations.xml
@@ -4,19 +4,15 @@
"Πρόσκληση ατόμων"
"Προέκυψε σφάλμα κατά τη δημιουργία της αίθουσας"
"Μόνο τα άτομα που έχουν προσκληθεί μπορούν να έχουν πρόσβαση σε αυτή την αίθουσα. Όλα τα μηνύματα είναι κρυπτογραφημένα από άκρο σε άκρο."
- "Ιδιωτική αίθουσα"
"Ο καθένας μπορεί να βρει αυτή την αίθουσα.
Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."
- "Δημόσια αίθουσα"
- "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα"
- "Οποιοσδήποτε"
- "Πρόσβαση στην Αίθουσα"
+ "Δημόσιο δωμάτιο"
"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα"
"Αίτημα συμμετοχής"
+ "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα"
+ "Οποιοσδήποτε"
"Για να είναι ορατή αυτή η αίθουσα στον δημόσιο κατάλογο αιθουσών, θα χρειαστείτε μια διεύθυνση αίθουσας."
"Διεύθυνση δωματίου"
- "Όνομα αίθουσας"
"Ορατότητα αίθουσας"
- "Δημιουργία αίθουσας"
"Θέμα (προαιρετικό)"
diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml
index 64806977c03..34362f537d3 100644
--- a/features/createroom/impl/src/main/res/values-es/translations.xml
+++ b/features/createroom/impl/src/main/res/values-es/translations.xml
@@ -4,18 +4,14 @@
"Invitar personas"
"Se ha producido un error al crear la sala"
"Solo las personas invitadas pueden acceder a esta sala. Todos los mensajes están cifrados de extremo a extremo."
- "Sala privada"
"Cualquiera puede encontrar esta sala.
Puedes cambiar esto en cualquier momento en los ajustes de la sala."
- "Sala pública"
- "Cualquiera puede unirse a esta sala"
- "Cualquiera"
- "Acceso a la sala"
+ "Sala pública (cualquiera)"
"Cualquiera puede solicitar unirse a la sala, pero un administrador o un moderador tendrá que aceptar la solicitud"
"Solicitud para unirse"
+ "Cualquiera puede unirse a esta sala"
+ "Cualquiera"
"Para que esta sala sea visible en el directorio de salas públicas, necesitarás una dirección de sala."
- "Nombre de la sala"
"Visibilidad de la sala"
- "Crear una sala"
"Tema (opcional)"
diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml
index 6a1d9dc58ae..0527302e0ed 100644
--- a/features/createroom/impl/src/main/res/values-et/translations.xml
+++ b/features/createroom/impl/src/main/res/values-et/translations.xml
@@ -4,19 +4,15 @@
"Kutsu osalejaid"
"Jututoa loomisel tekkis viga"
"Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud."
- "Privaatne jututuba"
"Kõik saavad seda jututuba leida.
Sa võid seda jututoa seadistustest alati muuta."
"Avalik jututuba"
- "Kõik võivad selle jututoaga liituda"
- "Kõik"
- "Ligipääs jututoale"
"Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama"
"Küsi võimalust liitumiseks"
+ "Kõik võivad selle jututoaga liituda"
+ "Kõik kasutajad"
"Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi."
"Jututoa aadress"
- "Jututoa nimi"
"Jututoa nähtavus"
- "Loo jututuba"
"Teema (kui soovid lisada)"
diff --git a/features/createroom/impl/src/main/res/values-eu/translations.xml b/features/createroom/impl/src/main/res/values-eu/translations.xml
index 537aa495a5b..7e4fefe08f0 100644
--- a/features/createroom/impl/src/main/res/values-eu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-eu/translations.xml
@@ -4,16 +4,12 @@
"Gonbidatu jendea"
"Errorea gertatu da gela sortzean"
"Gonbidatutako jendea soilik sar daiteke gelara. Mezu guztiak daude ertzetik ertzera zifratuta."
- "Gela pribatua"
"Edonork aurki dezake gela hau.
Gelaren ezarpenetan aldatu dezakezu hobespena."
"Gela publikoa"
- "Edonor sar daiteke gela honetara"
- "Edonork"
- "Gelarako sarbidea"
+ "Edonor sar daiteke gela honetara"
+ "Edonork"
"Gelaren helbidea"
- "Gelaren izena"
"Gelaren ikusgarritasuna"
- "Sortu gela"
"Mintzagaia (aukerakoa)"
diff --git a/features/createroom/impl/src/main/res/values-fa/translations.xml b/features/createroom/impl/src/main/res/values-fa/translations.xml
index 09869c76f6c..499b0a24ada 100644
--- a/features/createroom/impl/src/main/res/values-fa/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fa/translations.xml
@@ -7,14 +7,11 @@
"اتاق خصوصی"
"هرکسی میتواند اتاق را بیابد.
میتوانید بعداً در تظیمات اتاق عوضش کنید."
- "اتاق عمومی"
- "هرکسی میتواند به این اتاق بپیوندد"
- "هرکسی"
- "دسترسی اتاق"
+ "اتاق عمومی (هرکسی)"
"درخواست دعوت"
+ "هرکسی میتواند به این اتاق بپیوندد"
+ "هرکسی"
"نشانی اتاق"
- "نام اتاق"
"نمایانی اتاق"
- "ایجاد اتاق"
"موضوع (اختیاری)"
diff --git a/features/createroom/impl/src/main/res/values-fi/translations.xml b/features/createroom/impl/src/main/res/values-fi/translations.xml
index df541d3dee2..e4369839891 100644
--- a/features/createroom/impl/src/main/res/values-fi/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fi/translations.xml
@@ -4,19 +4,15 @@
"Kutsu henkilöitä"
"Huoneen luomisessa tapahtui virhe"
"Vain kutsutut henkilöt pääsevät tähän huoneeseen. Kaikki viestit ovat päästä päähän salattuja."
- "Yksityinen huone"
"Kuka tahansa voi löytää tämän huoneen.
Voit muuttaa tämän milloin tahansa huoneen asetuksista."
"Julkinen huone"
- "Kuka tahansa voi liittyä tähän huoneeseen"
- "Kuka tahansa"
- "Huoneeseen Pääsy"
"Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö"
"Pyydä liittymistä"
+ "Kuka tahansa voi liittyä tähän huoneeseen"
+ "Kuka tahansa"
"Jotta tämä huone näkyisi julkisessa huonehakemistossa, tarvitset huoneen osoitteen."
"Huoneen osoite"
- "Huoneen nimi"
"Huoneen näkyvyys"
- "Luo huone"
"Aihe (valinnainen)"
diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml
index afbdc919ba7..72b53148939 100644
--- a/features/createroom/impl/src/main/res/values-fr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fr/translations.xml
@@ -3,20 +3,26 @@
"Nouveau salon"
"Inviter des amis"
"Une erreur s’est produite lors de la création du salon"
- "Seules les personnes invitées peuvent accéder à ce salon. Tous les messages sont chiffrés de bout en bout."
- "Salon privé"
+ "L’espace n’a pas pu être créé à cause d’une erreur inconnue. Réessayez plus tard."
+ "Ajouter un nom…"
+ "Nouveau salon"
+ "Nouvel espace"
+ "Seules les personnes invitées peuvent joindre."
+ "Privé"
"N’importe qui peut trouver ce salon.
Vous pouvez modifier cela à tout moment dans les paramètres du salon."
+ "Tout le monde peut joindre"
"Salon public"
- "Tout le monde peut rejoindre ce salon"
- "Tout le monde"
- "Accès au salon"
- "Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande"
- "Demander à rejoindre"
- "Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin d’une adresse de salon."
- "Adresse du salon"
- "Nom du salon"
+ "Tout le monde peut demander à joindre, mais un administrateur ou un modérateur devra accepter la demande"
+ "Autoriser la demande à joindre"
+ "Seules les personnes invitées peuvent joindre."
+ "Privé"
+ "Tout le monde peut joindre"
+ "Tout le monde"
+ "Qui a accès"
+ "Vous aurez besoin d’une adresse pour qu’il soit visible dans le répertoire public."
+ "Adresse"
"Visibilité du salon"
- "Créer un salon"
"Sujet (facultatif)"
+ "Ajouter une description…"
diff --git a/features/createroom/impl/src/main/res/values-hr/translations.xml b/features/createroom/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..1a23597ff4b
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Nova soba"
+ "Pozovi osobe"
+ "Došlo je do pogreške prilikom stvaranja sobe"
+ "Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."
+ "Svatko može pronaći ovu sobu.
+To možete u svakom trenutku promijeniti u postavkama sobe."
+ "Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev."
+ "Zatraži pridruživanje"
+ "Svatko se može pridružiti ovoj sobi"
+ "Svatko"
+ "Da bi ova soba bila vidljiva u javnom direktoriju soba, trebat će vam adresa sobe."
+ "Adresa sobe"
+ "Vidljivost sobe"
+ "Tema (neobavezno)"
+
diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml
index 24f71983ce2..2e80833b2e4 100644
--- a/features/createroom/impl/src/main/res/values-hu/translations.xml
+++ b/features/createroom/impl/src/main/res/values-hu/translations.xml
@@ -4,19 +4,15 @@
"Ismerősök meghívása"
"Hiba történt a szoba létrehozásakor"
"Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve."
- "Privát szoba"
"Bárki megtalálhatja ezt a szobát.
Ezt bármikor módosíthatja a szobabeállításokban."
"Nyilvános szoba"
- "Bárki csatlakozhat ehhez a szobához"
- "Bárki"
- "Szobahozzáférés"
"Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést"
"Csatlakozás kérése"
+ "Bárki csatlakozhat ehhez a szobához"
+ "Bárki"
"Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét."
"Szoba címe"
- "Szoba neve"
"Szoba láthatósága"
- "Szoba létrehozása"
"Téma (nem kötelező)"
diff --git a/features/createroom/impl/src/main/res/values-in/translations.xml b/features/createroom/impl/src/main/res/values-in/translations.xml
index 219f621068f..20f23fd5c94 100644
--- a/features/createroom/impl/src/main/res/values-in/translations.xml
+++ b/features/createroom/impl/src/main/res/values-in/translations.xml
@@ -4,19 +4,15 @@
"Undang orang-orang"
"Terjadi kesalahan saat membuat ruangan"
"Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung."
- "Ruangan pribadi"
"Siapa pun dapat mencari ruangan ini.
Anda dapat mengubah ini kapan pun dalam pengaturan ruangan."
"Ruangan publik"
- "Siapa pun dapat bergabung dengan ruangan ini"
- "Siapa pun"
- "Akses Ruangan"
"Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut"
"Minta untuk bergabung"
+ "Siapa pun dapat bergabung dengan ruangan ini"
+ "Siapa pun"
"Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan."
"Alamat ruangan"
- "Nama ruangan"
"Keterlihatan ruangan"
- "Buat ruangan"
"Topik (opsional)"
diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml
index 741b88b7635..03d4c140fc3 100644
--- a/features/createroom/impl/src/main/res/values-it/translations.xml
+++ b/features/createroom/impl/src/main/res/values-it/translations.xml
@@ -4,19 +4,15 @@
"Invita persone"
"Si è verificato un errore durante la creazione della stanza"
"Solo le persone invitate possono accedere a questa stanza. Tutti i messaggi sono cifrati end-to-end."
- "Stanza privata"
"Chiunque può trovare questa stanza.
Puoi modificarlo in qualsiasi momento nelle impostazioni della stanza."
"Stanza pubblica"
- "Chiunque può entrare in questa stanza"
- "Chiunque"
- "Accesso alla stanza"
"Chiunque può chiedere di entrare nella stanza, ma un amministratore o un moderatore dovrà accettare la richiesta"
"Chiedi di entrare"
+ "Chiunque può entrare in questa stanza"
+ "Chiunque"
"Affinché questa stanza sia visibile nell\'elenco delle stanze pubbliche, è necessario un indirizzo della stanza."
"Indirizzo della stanza"
- "Nome stanza"
"Visibilità della stanza"
- "Crea una stanza"
"Argomento (facoltativo)"
diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml
index 20c7af40de1..22fc67afced 100644
--- a/features/createroom/impl/src/main/res/values-ka/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ka/translations.xml
@@ -4,10 +4,8 @@
"ხალხის მოწვევა"
"ოთახის შექმნისას შეცდომა მოხდა"
"ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია."
- "კერძო ოთახი"
"ყველას ამ ოთახის მოძებნა შეუძლია.
თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში."
- "ოთახის სახელი"
- "ოთახის შექმნა"
+ "საჯარო ოთახი"
"თემა (სურვილისამებრ)"
diff --git a/features/createroom/impl/src/main/res/values-ko/translations.xml b/features/createroom/impl/src/main/res/values-ko/translations.xml
index 3dfdc46e7cd..b1cd90bbf19 100644
--- a/features/createroom/impl/src/main/res/values-ko/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ko/translations.xml
@@ -4,18 +4,14 @@
"사람 초대하기"
"방을 생성하던 중 오류가 발생했어요"
"초대받은 사람만 이 방에 액세스할 수 있습니다. 모든 메시지는 종단 간 암호화됩니다."
- "비공개 방"
"누구나 이 방을 찾을 수 있습니다.
방 설정에서 언제든지 변경할 수 있습니다."
- "공개 방"
- "누구나 이 방에 참여할 수 있습니다."
- "누구나"
- "방 액세스"
+ "공개 방 (모두)"
"누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다."
"참가 요청"
+ "누구나 이 방에 참여할 수 있습니다."
+ "누구나"
"이 방이 공개 방 디렉토리에 표시되려면 방 주소가 필요합니다."
- "방 이름"
"방 표시 여부"
- "방 만들기"
"주제 (선택)"
diff --git a/features/createroom/impl/src/main/res/values-lt/translations.xml b/features/createroom/impl/src/main/res/values-lt/translations.xml
index 2fb7b3eb5c4..3d5566cb151 100644
--- a/features/createroom/impl/src/main/res/values-lt/translations.xml
+++ b/features/createroom/impl/src/main/res/values-lt/translations.xml
@@ -4,10 +4,7 @@
"Pakviesti žmonių"
"Kuriant kambarį įvyko klaida"
"Į šį kambarį gali patekti tik pakviesti žmonės. Visi pranešimai yra užšifruoti nuo pradžios iki galo."
- "Privatus kambarys"
"Bet kas gali rasti šį kambarį.
Tai galite bet kada pakeisti kambario nustatymuose."
- "Kambario pavadinimas"
- "Kurti kambarį"
"Tema (nebūtina)"
diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml
index 9a159811124..e5ad63c84b8 100644
--- a/features/createroom/impl/src/main/res/values-nb/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nb/translations.xml
@@ -4,18 +4,15 @@
"Inviter folk"
"Det oppsto en feil under opprettelsen av rommet"
"Bare inviterte personer har tilgang til dette rommet. Alle meldinger er ende-til-ende-kryptert."
- "Privat rom"
"Alle kan finne dette rommet.
Du kan endre dette når som helst i rominnstillingene."
"Offentlig rom"
- "Alle kan bli med i dette rommet"
- "Alle"
- "Tilgang til rom"
"Alle kan be om å få bli med i rommet, men en administrator eller moderator må godta forespørselen"
"Be om å bli med"
+ "Alle kan bli med i dette rommet"
+ "Alle"
"For at dette rommet skal være synlig i den offentlige romkatalogen, trenger du en romadresse."
- "Romnavn"
+ "Romadresse"
"Romsynlighet"
- "Opprett et rom"
"Emne (valgfritt)"
diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml
index 5142148171f..4691cb1f198 100644
--- a/features/createroom/impl/src/main/res/values-nl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-nl/translations.xml
@@ -4,16 +4,12 @@
"Mensen uitnodigen"
"Er is een fout opgetreden bij het aanmaken van de kamer"
"Alleen uitgenodigde personen hebben toegang tot deze kamer. Alle berichten zijn end-to-end versleuteld."
- "Privé kamer"
"Iedereen kan deze kamer vinden.
Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen."
"Openbare kamer"
- "Iedereen kan toetreden tot deze kamer"
- "Iedereen"
- "Toegang tot de kamer"
"Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren"
"Vraag om toe te treden"
- "Naam van de kamer"
- "Creëer een kamer"
+ "Iedereen kan toetreden tot deze kamer"
+ "Iedereen"
"Onderwerp (optioneel)"
diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml
index 446644b6222..325d40cf5bb 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -4,19 +4,15 @@
"Zaproś znajomych"
"Wystąpił błąd w trakcie tworzenia pokoju"
"Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end."
- "Pokój prywatny"
"Każdy może znaleźć ten pokój.
Możesz to zmienić w ustawieniach pokoju."
"Pokój publiczny"
- "Każdy może dołączyć do tego pokoju"
- "Wszyscy"
- "Dostęp do pokoju"
"Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę"
"Poproś o dołączenie"
+ "Każdy może dołączyć do tego pokoju"
+ "Wszyscy"
"Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."
"Adres pokoju"
- "Nazwa pokoju"
"Widoczność pomieszczenia"
- "Utwórz pokój"
"Temat (opcjonalnie)"
diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
index 399c9fec170..174f42db06a 100644
--- a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml
@@ -3,20 +3,21 @@
"Nova sala"
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
- "Apenas as pessoas convidadas podem entrar nesta sala. Todas as mensagens são criptografadas de ponta a ponta."
- "Sala privada"
+ "O espaço não pôde ser criado por conta de um erro desconhecido. Tente novamente mais tarde."
+ "Novo espaço"
+ "Apenas pessoas convidadas podem entrar."
+ "Privada"
"Qualquer um pode encontrar esta sala.
Você pode mudar isso a qualquer momento nas configurações da sala."
- "Sala pública"
- "Qualquer pessoa pode entrar nesta sala"
- "Qualquer pessoa"
- "Acesso à sala"
- "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador terá de aceitar a solicitação"
+ "Qualquer um pode entrar."
+ "Publica"
+ "Qualquer um pode pedir para entrar, mas um administrador ou moderador deve aceitar a solicitação"
"Pedir para entrar"
+ "Qualquer um pode entrar."
+ "Qualquer pessoa"
+ "Quem tem acesso"
"Para que esta sala fique visível no diretório público de salas, você precisará de um endereço de sala."
"Endereço da sala"
- "Nome da sala"
"Visibilidade da sala"
- "Criar uma sala"
"Tópico (opcional)"
diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt/translations.xml
index 1524914bb2a..6225f531a5d 100644
--- a/features/createroom/impl/src/main/res/values-pt/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pt/translations.xml
@@ -4,19 +4,15 @@
"Convidar pessoas"
"Ocorreu um erro ao criar a sala"
"Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta."
- "Sala privada"
"Qualquer um pode encontrar esta sala.
Pode alterar esta opção nas definições da sala."
"Sala pública"
- "Qualquer pessoa pode entrar nesta sala"
- "Qualquer pessoa"
- "Acesso à sala"
"Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido"
"Pedir para participar"
+ "Qualquer pessoa pode entrar nesta sala"
+ "Qualquer pessoa"
"Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala."
"Endereço da sala"
- "Nome da sala"
"Visibilidade da sala"
- "Criar uma sala"
"Descrição (opcional)"
diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml
index b9fe78bb198..01ce6bfa38e 100644
--- a/features/createroom/impl/src/main/res/values-ro/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ro/translations.xml
@@ -4,19 +4,15 @@
"Invitați prieteni"
"A apărut o eroare la crearea camerei"
"Doar persoanele invitate pot accesa această cameră. Toate mesajele sunt criptate end-to-end."
- "Cameră privată"
"Oricine poate găsi această cameră.
Puteți modifica acest lucru oricând în setări."
"Cameră publică"
- "Oricine se poate alătura acestei camere"
- "Oricine"
- "Acces la cameră"
"Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea"
"Cereți să vă alăturați"
+ "Oricine se poate alătura acestei camere"
+ "Oricine"
"Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."
"Adresa camerei"
- "Numele camerei"
"Vizibilitatea camerei"
- "Creați o cameră"
"Subiect (opțional)"
diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml
index e8716731148..4a009eaa104 100644
--- a/features/createroom/impl/src/main/res/values-ru/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ru/translations.xml
@@ -4,19 +4,15 @@
"Пригласить в комнату"
"Произошла ошибка при создании комнаты"
"Доступ в эту комнату имеют только приглашенные пользователи. Все сообщения защищены сквозным шифрованием."
- "Частная комната"
"Любой желающий может найти эту комнату.
Вы можете изменить это в любое время в настройках комнаты."
"Общедоступная комната"
- "Любой желающий может присоединиться к этой комнате"
- "Любой"
- "Доступ в комнату"
"Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос."
"Попросить присоединиться"
+ "Любой желающий может присоединиться к этой комнате"
+ "Любой"
"Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес"
"Адрес комнаты"
- "Название комнаты"
"Видимость комнаты"
- "Создать комнату"
"Тема (необязательно)"
diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml
index 7b6d89b2e1d..555571fba9e 100644
--- a/features/createroom/impl/src/main/res/values-sk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sk/translations.xml
@@ -4,19 +4,15 @@
"Pozvať ľudí"
"Pri vytváraní miestnosti došlo k chybe"
"Do tejto miestnosti majú prístup iba pozvaní ľudia. Všetky správy sú end-to-end šifrované."
- "Súkromná miestnosť"
"Túto miestnosť môže nájsť ktokoľvek.
Môžete to kedykoľvek zmeniť v nastaveniach miestnosti."
"Verejná miestnosť"
- "Do tejto miestnosti sa môže pripojiť ktokoľvek"
- "Ktokoľvek"
- "Prístup do miestnosti"
"Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť"
"Požiadať o pripojenie"
+ "Do tejto miestnosti sa môže pripojiť ktokoľvek"
+ "Ktokoľvek"
"Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti."
"Adresa miestnosti"
- "Názov miestnosti"
"Viditeľnosť miestnosti"
- "Vytvoriť miestnosť"
"Téma (voliteľné)"
diff --git a/features/createroom/impl/src/main/res/values-sv/translations.xml b/features/createroom/impl/src/main/res/values-sv/translations.xml
index 8cd01ebd0ef..779bd893eac 100644
--- a/features/createroom/impl/src/main/res/values-sv/translations.xml
+++ b/features/createroom/impl/src/main/res/values-sv/translations.xml
@@ -4,19 +4,15 @@
"Bjud in personer"
"Ett fel uppstod när rummet skapades"
"Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade."
- "Privat rum"
"Vem som helst kan hitta det här rummet.
Du kan ändra detta när som helst i rumsinställningarna."
"Offentligt rum"
- "Vem som helst kan gå med i det här rummet"
- "Vem som helst"
- "Rumsåtkomst"
"Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran"
"Be om att gå med"
+ "Vem som helst kan gå med i det här rummet"
+ "Vem som helst"
"För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress."
"Rumsadress"
- "Rumsnamn"
"Rumssynlighet"
- "Skapa ett rum"
"Ämne (valfritt)"
diff --git a/features/createroom/impl/src/main/res/values-tr/translations.xml b/features/createroom/impl/src/main/res/values-tr/translations.xml
index d97139c973e..34406bb0fd8 100644
--- a/features/createroom/impl/src/main/res/values-tr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-tr/translations.xml
@@ -4,19 +4,15 @@
"İnsanları davet et"
"Oda oluşturulurken bir hata oluştu"
"Bu odaya yalnızca davet edilen kişiler erişebilir. Tüm mesajlar uçtan uca şifrelenir."
- "Özel oda"
"Bu odayı herkes bulabilir.
Bunu istediğiniz zaman oda ayarlarından değiştirebilirsiniz."
"Herkese açık oda"
- "Bu odaya herkes katılabilir"
- "Herkes"
- "Oda Erişimi"
"Herkes odaya katılmayı isteyebilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekecektir"
"Katılmak için sor"
+ "Bu odaya herkes katılabilir"
+ "Herkes"
"Bu odanın genel oda dizininde görünür olması için bir oda adresine ihtiyacınız olacaktır."
"Oda adresi"
- "Oda adı"
"Oda görünürlüğü"
- "Bir oda oluştur"
"Konu (isteğe bağlı)"
diff --git a/features/createroom/impl/src/main/res/values-uk/translations.xml b/features/createroom/impl/src/main/res/values-uk/translations.xml
index 047b4dd9d24..e665d5c2f36 100644
--- a/features/createroom/impl/src/main/res/values-uk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uk/translations.xml
@@ -4,19 +4,15 @@
"Запросити людей"
"Під час створення кімнати сталася помилка"
"Лише запрошені люди мають доступ до цієї кімнати. Усі повідомлення захищені наскрізним шифруванням."
- "Приватна кімната (тільки за запрошенням)"
"Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати."
- "Загальнодоступна кімната"
- "Будь-хто може приєднатися до цієї кімнати"
- "Кожний"
- "Доступ до кімнати"
+ "Публічна кімната"
"Будь-хто може попросити приєднатися до кімнати, але адміністратор або модератор повинен буде прийняти запит"
"Запросити приєднатися"
+ "Будь-хто може приєднатися до цієї кімнати"
+ "Кожний"
"Щоб цю кімнату було видно в каталозі загальнодоступних кімнат, вам знадобиться її адреса."
"Адреса кімнати"
- "Назва кімнати"
"Видимість кімнати"
- "Створити кімнату"
"Тема (необов\'язково)"
diff --git a/features/createroom/impl/src/main/res/values-ur/translations.xml b/features/createroom/impl/src/main/res/values-ur/translations.xml
index b68992085fe..ce1e23ffc9d 100644
--- a/features/createroom/impl/src/main/res/values-ur/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ur/translations.xml
@@ -4,11 +4,7 @@
"لوگوں کو مدعو کریں"
"کمرہ تخلیق کرتے ہوئے ایک نقص واقع ہوا"
"صرف مدعو لوگ ہی اس کمرے تک رسائی حاصل کر سکتے ہیں۔ تمام پیغامات آخر تا آخر مرموز کردہ ہیں۔"
- "نجی کمرہ"
"کوئی بھی یہ کمرہ ڈھونڈ سکتا ہے۔
آپ اسے کمرے کی ترتیبات میں کسی بھی وقت تبدیل کرسکتے ہیں۔"
- "عوامی کمرہ"
- "کمرے کا نام"
- "ایک کمرہ بنائیں"
"موضوع (اختیاری)"
diff --git a/features/createroom/impl/src/main/res/values-uz/translations.xml b/features/createroom/impl/src/main/res/values-uz/translations.xml
index 34062f96699..46905b5f1d5 100644
--- a/features/createroom/impl/src/main/res/values-uz/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uz/translations.xml
@@ -4,18 +4,14 @@
"Odamlarni taklif qiling"
"Xonani yaratishda xatolik yuz berdi"
"Faqat taklif etilgan shaxslargina bu xonaga kira oladi. Barcha xabarlar boshdan-oxirigacha shifrlanadi."
- "Shaxsiy xona"
"Bu xonani har kim topishi mumkin.
Buni xona sozlamalaridan istalgan vaqtda oʻzgartirishingiz mumkin."
- "Jamoat xonasi"
- "Bu xonaga istalgan kishi qo‘shilishi mumkin"
- "Har kim"
- "Xonaga kirish"
+ "Jamoat xonasi (har kim)"
"Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak"
"Qo‘shilishni so‘rang"
+ "Bu xonaga istalgan kishi qo‘shilishi mumkin"
+ "Har kim"
"Ushbu xona ommaviy xonalar ro‘yxatida ko‘rinishi uchun sizga xona manzili kerak bo‘ladi."
- "Xona nomi"
"Xonaning ko‘rinishi"
- "Xonani yaratish"
"Mavzu (ixtiyoriy)"
diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
index 476f9cff7eb..595b06d55e2 100644
--- a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml
@@ -4,19 +4,15 @@
"邀請夥伴"
"建立聊天室時發生錯誤"
"僅被邀請的人才能存取此聊天室。所有訊息均會端到端加密。"
- "私密聊天室"
"任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"
- "公開的聊天室"
- "任何人都可以加入此聊天室"
- "任何人"
- "聊天室存取權"
+ "公開聊天室"
"任何人都可以要求加入聊天室,但管理員或版主必須接受該請求"
"要求加入"
+ "任何人都可以加入此聊天室"
+ "任何人"
"為了讓此聊天室在公開聊天室目錄中可見,您需要聊天室地址。"
"聊天室地址"
- "聊天室名稱"
"聊天室能見度"
- "建立聊天室"
"主題(非必填)"
diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml
index d20e34801d0..1a189884cfd 100644
--- a/features/createroom/impl/src/main/res/values-zh/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh/translations.xml
@@ -4,19 +4,15 @@
"邀请朋友"
"创建聊天室时出错"
"只有受邀用户才能访问此聊天室。所有消息均经过端到端加密。"
- "私有聊天室"
"任何人都能找到此聊天室。
你可以随时在聊天室设置中更改。"
- "公共聊天室"
- "任何人都可以加入此房间"
- "任何人"
- "房间访问权限"
+ "公开聊天室"
"任何人都可以请求加入房间,但必须由管理员或审核人接受"
"请求加入"
+ "任何人都可以加入此房间"
+ "任何人"
"要使该房间在公开房间目录中可见,您需要一个房间地址。"
"房间地址"
- "聊天室名称"
"房间可见性"
- "创建聊天室"
"主题(可选)"
diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml
index fa9a1cb2760..3ec2d9d6f32 100644
--- a/features/createroom/impl/src/main/res/values/localazy.xml
+++ b/features/createroom/impl/src/main/res/values/localazy.xml
@@ -3,20 +3,26 @@
"New room"
"Invite people"
"An error occurred when creating the room"
- "Only people invited can access this room. All messages are end-to-end encrypted."
- "Private room"
+ "The space could not be created because of an unknown error. Try again later."
+ "Add name…"
+ "New room"
+ "New space"
+ "Only people invited can join."
+ "Private"
"Anyone can find this room.
You can change this anytime in room settings."
- "Public room"
- "Anyone can join this room"
- "Anyone"
- "Room Access"
- "Anyone can ask to join the room but an administrator or a moderator will have to accept the request"
- "Ask to join"
- "In order for this room to be visible in the public room directory, you will need a room address."
- "Room address"
- "Room name"
+ "Anyone can join."
+ "Public"
+ "Anyone can ask to join but an administrator or a moderator must accept the request."
+ "Allow ask to join"
+ "Only people invited can join."
+ "Private"
+ "Anyone can join."
+ "Public"
+ "Who has access"
+ "You’ll need an address in order to make it visible in the public directory."
+ "Address"
"Room visibility"
- "Create a room"
"Topic (optional)"
+ "Add description…"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt
index 35b6637bbf6..5b7a6c1142c 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt
@@ -40,6 +40,7 @@ class DefaultCreateRoomEntryPointTest {
override fun onRoomCreated(roomId: RoomId) = lambdaError()
}
val result = entryPoint.createNode(
+ isSpace = false,
parentNode = parentNode,
buildContext = BuildContext.root(null),
callback = callback,
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt
index c8a6c2bd8a9..e82d8c39129 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt
@@ -51,15 +51,10 @@ 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.test
-import io.mockk.every
import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -76,17 +71,6 @@ class ConfigureRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- @Before
- fun setup() {
- mockkStatic(File::readBytes)
- every { any().readBytes() } returns byteArrayOf()
- }
-
- @After
- fun tearDown() {
- unmockkAll()
- }
-
@Test
fun `present - initial state`() = runTest {
val presenter = createConfigureRoomPresenter()
@@ -261,20 +245,25 @@ class ConfigureRoomPresenterTest {
val initialState = initialState()
dataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY))
skipItems(1)
- mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk())))
- matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
-
- initialState.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- val stateAfterCreateRoom = awaitItem()
- assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
- assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
-
- matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
- stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
- assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
+ val file = File.createTempFile("test", "jpg")
+ try {
+ mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(file, mockk(), mockk())))
+ matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION))
+
+ initialState.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ val stateAfterCreateRoom = awaitItem()
+ assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java)
+ assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty()
+
+ matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL))
+ stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java)
+ assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java)
+ } finally {
+ file.delete()
+ }
}
}
@@ -391,6 +380,7 @@ class ConfigureRoomPresenterTest {
)
private fun createConfigureRoomPresenter(
+ isSpace: Boolean = false,
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(),
@@ -401,6 +391,7 @@ class ConfigureRoomPresenterTest {
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter(
+ isSpace = isSpace,
dataStore = dataStore,
matrixClient = matrixClient,
mediaPickerProvider = pickerProvider,
diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt
index 2beaecf0135..bbeb69c26ba 100644
--- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt
+++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt
@@ -14,6 +14,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakeCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode(
+ isSpace: Boolean,
parentNode: Node,
buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback,
diff --git a/features/deactivation/impl/src/main/res/values-hr/translations.xml b/features/deactivation/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..04148fdc482
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,14 @@
+
+
+ "Potvrdite da želite deaktivirati svoj račun. Ova se radnja ne može poništiti."
+ "Izbriši sve moje poruke"
+ "Upozorenje: budući korisnici mogu vidjeti nepotpune razgovore."
+ "Deaktiviranje vašeg računa je %1$s, to će:"
+ "nepovratno"
+ "%1$s vaš račun (ne možete se ponovno prijaviti i vaš ID se ne može ponovno upotrijebiti)."
+ "Trajno onemogući"
+ "Ukloniti vas iz svih soba za razgovore."
+ "Izbrisati podatke o vašem računu s našeg poslužitelja identiteta."
+ "Vaše će poruke i dalje biti vidljive registriranim korisnicima, ali neće biti dostupne novim ili neregistriranim korisnicima ako ih odlučite izbrisati."
+ "Deaktiviraj račun"
+
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
index 344d83c38f4..deb7fc59b90 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
@@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.permissions.api.PermissionStateProvider
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@@ -58,7 +58,7 @@ class NotificationsOptInPresenter(
if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished()
} else {
- notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ notificationsPermissionsState.eventSink(PermissionsEvent.RequestPermissions)
}
}
NotificationsOptInEvents.NotNowClicked -> {
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
index e4e922f9339..8f72f038de7 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt
@@ -25,6 +25,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
@@ -111,13 +112,7 @@ private fun ChooseSelfVerificationModeButtons(
AsyncData.Uninitialized,
is AsyncData.Failure,
is AsyncData.Loading -> {
- Button(
- modifier = Modifier.fillMaxWidth(),
- enabled = false,
- showProgress = true,
- text = stringResource(CommonStrings.common_loading),
- onClick = {},
- )
+ LoadingButtonAtom()
}
is AsyncData.Success -> {
if (state.buttonsState.data.canUseAnotherDevice) {
diff --git a/features/ftue/impl/src/main/res/values-hr/translations.xml b/features/ftue/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..cb0e3308729
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Ne možete potvrditi?"
+ "Izradi novi ključ za oporavak"
+ "Potvrdite ovaj uređaj kako biste postavili sigurnu razmjenu poruka."
+ "Potvrdite svoj identitet"
+ "Upotrijebite drugi uređaj"
+ "Upotrijebi ključ za oporavak"
+ "Sada možete sigurno čitati ili slati poruke, a svatko s kim razgovarate također može vjerovati ovom uređaju."
+ "Uređaj je potvrđen"
+ "Upotrijebite drugi uređaj"
+ "Čekanje na drugi uređaj…"
+ "Postavke možete promijeniti poslije."
+ "Omogućite obavijesti i nikada ne propustite poruku"
+ "Unesi ključ za oporavak"
+
diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
index 71ee093985c..1a902456116 100644
--- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
+++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt
@@ -25,6 +25,7 @@ interface HomeEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?)
fun navigateToCreateRoom()
+ fun navigateToCreateSpace()
fun navigateToSettings()
fun navigateToSetUpRecovery()
fun navigateToEnterRecoveryKey()
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
index d9f87e2edd6..0e92761bd5c 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt
@@ -220,6 +220,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onStartChatClick = callback::navigateToCreateRoom,
+ onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
onConfirmRecoveryKeyClick = callback::navigateToEnterRecoveryKey,
onRoomSettingsClick = callback::navigateToRoomSettings,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
index e53d20857eb..3f223135c11 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt
@@ -28,8 +28,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
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.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -48,7 +46,6 @@ class HomePresenter(
private val homeSpacesPresenter: Presenter,
private val logoutPresenter: Presenter,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
- private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
private val announcementService: AnnouncementService,
) : Presenter {
@@ -69,9 +66,6 @@ class HomePresenter(
val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false)
val roomListState = roomListPresenter.present()
val homeSpacesState = homeSpacesPresenter.present()
- val isSpaceFeatureEnabled by remember {
- featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space)
- }.collectAsState(initial = false)
var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) }
val currentHomeNavigationBarItem by remember {
derivedStateOf {
@@ -117,7 +111,6 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
directLogoutState = directLogoutState,
- isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = ::handleEvent,
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
index 90667a87341..474fb6d5baa 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt
@@ -29,10 +29,9 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val directLogoutState: DirectLogoutState,
- val isSpaceFeatureEnabled: Boolean,
val eventSink: (HomeEvents) -> Unit,
) {
val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats
val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters
- val showNavigationBar = isSpaceFeatureEnabled && homeSpacesState.spaceRooms.isNotEmpty()
+ val showNavigationBar = homeSpacesState.spaceRooms.isNotEmpty()
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
index 43010b17202..e68ff7aa1fb 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt
@@ -31,7 +31,6 @@ open class HomeStateProvider : PreviewParameterProvider {
aHomeState(hasNetworkConnection = false),
aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)),
aHomeState(
- isSpaceFeatureEnabled = true,
roomListState = aRoomListState(
// Add more rooms to see the blur effect under the NavigationBar
contentState = aRoomsContentState(
@@ -42,7 +41,6 @@ open class HomeStateProvider : PreviewParameterProvider {
homeSpacesState = aHomeSpacesState(),
),
aHomeState(
- isSpaceFeatureEnabled = true,
currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces,
),
) + RoomListStateProvider().values.map {
@@ -60,7 +58,6 @@ internal fun aHomeState(
roomListState: RoomListState = aRoomListState(),
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
- isSpaceFeatureEnabled: Boolean = false,
directLogoutState: DirectLogoutState = aDirectLogoutState(),
eventSink: (HomeEvents) -> Unit = {}
) = HomeState(
@@ -73,6 +70,5 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
- isSpaceFeatureEnabled = isSpaceFeatureEnabled,
eventSink = eventSink,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
index eb7f3157a86..516a20e0f8d 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt
@@ -77,6 +77,7 @@ fun HomeView(
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
+ onCreateSpaceClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
@@ -116,6 +117,7 @@ fun HomeView(
onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) },
onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() },
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
+ onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
)
// This overlaid view will only be visible when state.displaySearchResults is true
@@ -141,6 +143,7 @@ private fun HomeScaffold(
onRoomClick: (RoomId) -> Unit,
onOpenSettings: () -> Unit,
onStartChatClick: () -> Unit,
+ onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -181,6 +184,7 @@ private fun HomeScaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
HomeTopBar(
+ selectedNavigationItem = state.currentHomeNavigationBarItem,
title = stringResource(state.currentHomeNavigationBarItem.labelRes),
currentUserAndNeighbors = state.currentUserAndNeighbors,
showAvatarIndicator = state.showAvatarIndicator,
@@ -191,19 +195,16 @@ private fun HomeScaffold(
onAccountSwitch = {
state.eventSink(HomeEvents.SwitchToAccount(it))
},
+ onCreateSpace = onCreateSpaceClick,
scrollBehavior = scrollBehavior,
- displayMenuItems = state.displayActions,
displayFilters = state.displayRoomListFilters,
filtersState = roomListState.filtersState,
+ canCreateSpaces = state.homeSpacesState.canCreateSpaces,
canReportBug = state.canReportBug,
- modifier = if (state.isSpaceFeatureEnabled) {
- Modifier.hazeEffect(
- state = hazeState,
- style = HazeMaterials.thick(),
- )
- } else {
- Modifier.background(ElementTheme.colors.bgCanvasDefault)
- }
+ modifier = Modifier.hazeEffect(
+ state = hazeState,
+ style = HazeMaterials.thick(),
+ )
)
},
bottomBar = {
@@ -354,6 +355,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state:
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
+ onCreateSpaceClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
@@ -373,6 +375,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
+ onCreateSpaceClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
index 093b91fb661..c92a5b9fb8f 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt
@@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.home.impl.HomeNavigationBarItem
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.RoomListFiltersView
@@ -73,6 +74,7 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTopBar(
+ selectedNavigationItem: HomeNavigationBarItem,
title: String,
currentUserAndNeighbors: ImmutableList,
showAvatarIndicator: Boolean,
@@ -81,8 +83,9 @@ fun HomeTopBar(
onMenuActionClick: (RoomListMenuAction) -> Unit,
onOpenSettings: () -> Unit,
onAccountSwitch: (SessionId) -> Unit,
+ onCreateSpace: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
- displayMenuItems: Boolean,
+ canCreateSpaces: Boolean,
canReportBug: Boolean,
displayFilters: Boolean,
filtersState: RoomListFiltersState,
@@ -117,63 +120,16 @@ fun HomeTopBar(
)
},
actions = {
- if (displayMenuItems) {
- IconButton(
- onClick = onToggleSearch,
- ) {
- Icon(
- imageVector = CompoundIcons.Search(),
- contentDescription = stringResource(CommonStrings.action_search),
- )
- }
- if (RoomListConfig.HAS_DROP_DOWN_MENU) {
- var showMenu by remember { mutableStateOf(false) }
- IconButton(
- onClick = { showMenu = !showMenu }
- ) {
- Icon(
- imageVector = CompoundIcons.OverflowVertical(),
- contentDescription = null,
- )
- }
- DropdownMenu(
- expanded = showMenu,
- onDismissRequest = { showMenu = false }
- ) {
- if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
- DropdownMenuItem(
- onClick = {
- showMenu = false
- onMenuActionClick(RoomListMenuAction.InviteFriends)
- },
- text = { Text(stringResource(id = CommonStrings.action_invite)) },
- leadingIcon = {
- Icon(
- imageVector = CompoundIcons.ShareAndroid(),
- tint = ElementTheme.colors.iconSecondary,
- contentDescription = null,
- )
- }
- )
- }
- if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
- DropdownMenuItem(
- onClick = {
- showMenu = false
- onMenuActionClick(RoomListMenuAction.ReportBug)
- },
- text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
- leadingIcon = {
- Icon(
- imageVector = CompoundIcons.ChatProblem(),
- tint = ElementTheme.colors.iconSecondary,
- contentDescription = null,
- )
- }
- )
- }
- }
- }
+ when (selectedNavigationItem) {
+ HomeNavigationBarItem.Chats -> RoomListMenuItems(
+ onToggleSearch = onToggleSearch,
+ onMenuActionClick = onMenuActionClick,
+ canReportBug = canReportBug
+ )
+ HomeNavigationBarItem.Spaces -> SpacesMenuItems(
+ canCreateSpaces = canCreateSpaces,
+ onCreateSpace = onCreateSpace
+ )
}
},
// We want a 16dp left padding for the navigationIcon :
@@ -193,6 +149,85 @@ fun HomeTopBar(
}
}
+@Composable
+private fun RoomListMenuItems(
+ onToggleSearch: () -> Unit,
+ onMenuActionClick: (RoomListMenuAction) -> Unit,
+ canReportBug: Boolean,
+) {
+ IconButton(
+ onClick = onToggleSearch,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Search(),
+ contentDescription = stringResource(CommonStrings.action_search),
+ )
+ }
+ if (RoomListConfig.HAS_DROP_DOWN_MENU) {
+ var showMenu by remember { mutableStateOf(false) }
+ IconButton(
+ onClick = { showMenu = !showMenu }
+ ) {
+ Icon(
+ imageVector = CompoundIcons.OverflowVertical(),
+ contentDescription = null,
+ )
+ }
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ if (RoomListConfig.SHOW_INVITE_MENU_ITEM) {
+ DropdownMenuItem(
+ onClick = {
+ showMenu = false
+ onMenuActionClick(RoomListMenuAction.InviteFriends)
+ },
+ text = { Text(stringResource(id = CommonStrings.action_invite)) },
+ leadingIcon = {
+ Icon(
+ imageVector = CompoundIcons.ShareAndroid(),
+ tint = ElementTheme.colors.iconSecondary,
+ contentDescription = null,
+ )
+ }
+ )
+ }
+ if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) {
+ DropdownMenuItem(
+ onClick = {
+ showMenu = false
+ onMenuActionClick(RoomListMenuAction.ReportBug)
+ },
+ text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
+ leadingIcon = {
+ Icon(
+ imageVector = CompoundIcons.ChatProblem(),
+ tint = ElementTheme.colors.iconSecondary,
+ contentDescription = null,
+ )
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SpacesMenuItems(
+ canCreateSpaces: Boolean,
+ onCreateSpace: () -> Unit
+) {
+ if (canCreateSpaces) {
+ IconButton(onClick = onCreateSpace) {
+ Icon(
+ imageVector = CompoundIcons.Plus(),
+ contentDescription = stringResource(CommonStrings.action_create_space)
+ )
+ }
+ }
+}
+
@Composable
private fun NavigationIcon(
currentUserAndNeighbors: ImmutableList,
@@ -273,6 +308,7 @@ private fun AccountIcon(
@Composable
internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = false,
@@ -281,7 +317,8 @@ internal fun HomeTopBarPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
- displayMenuItems = true,
+ onCreateSpace = {},
+ canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
@@ -289,11 +326,35 @@ internal fun HomeTopBarPreview() = ElementPreview {
)
}
+@OptIn(ExperimentalMaterial3Api::class)
+@PreviewsDayNight
+@Composable
+internal fun HomeTopBarSpacesPreview() = ElementPreview {
+ HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Spaces,
+ title = stringResource(R.string.screen_home_tab_spaces),
+ currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ showAvatarIndicator = false,
+ areSearchResultsDisplayed = false,
+ scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
+ onOpenSettings = {},
+ onAccountSwitch = {},
+ onToggleSearch = {},
+ onCreateSpace = {},
+ canCreateSpaces = true,
+ canReportBug = true,
+ displayFilters = false,
+ filtersState = aRoomListFiltersState(),
+ onMenuActionClick = {},
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
showAvatarIndicator = true,
@@ -302,7 +363,8 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
- displayMenuItems = true,
+ onCreateSpace = {},
+ canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
@@ -315,6 +377,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
@Composable
internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
HomeTopBar(
+ selectedNavigationItem = HomeNavigationBarItem.Chats,
title = stringResource(R.string.screen_roomlist_main_space_title),
currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(),
showAvatarIndicator = false,
@@ -323,7 +386,8 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview {
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
- displayMenuItems = true,
+ onCreateSpace = {},
+ canCreateSpaces = true,
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
index 12df2be250a..dc4ea5b50f0 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt
@@ -46,6 +46,7 @@ import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider
import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListEvents
import io.element.android.libraries.core.extensions.orEmpty
+import io.element.android.libraries.core.extensions.toSafeLength
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.avatar.Avatar
@@ -122,7 +123,6 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
- latestEvent = room.latestEvent,
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
@@ -139,7 +139,6 @@ internal fun RoomSummaryRow(
) {
NameAndTimestampRow(
name = room.name,
- latestEvent = room.latestEvent,
timestamp = null,
isHighlighted = room.isHighlighted
)
@@ -215,7 +214,6 @@ private fun RoomSummaryScaffoldRow(
@Composable
private fun NameAndTimestampRow(
name: String?,
- latestEvent: LatestEvent,
timestamp: String?,
isHighlighted: Boolean,
modifier: Modifier = Modifier
@@ -231,34 +229,12 @@ private fun NameAndTimestampRow(
// Name
Text(
style = ElementTheme.typography.fontBodyLgMedium,
- text = name ?: stringResource(id = CommonStrings.common_no_room_name),
+ text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
- // Picto
- when (latestEvent) {
- is LatestEvent.Sending -> {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- modifier = Modifier.size(16.dp),
- imageVector = CompoundIcons.Time(),
- contentDescription = null,
- tint = ElementTheme.colors.iconTertiary,
- )
- }
- is LatestEvent.Error -> {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- modifier = Modifier.size(16.dp),
- imageVector = CompoundIcons.ErrorSolid(),
- contentDescription = null,
- tint = ElementTheme.colors.iconCriticalPrimary,
- )
- }
- else -> Unit
- }
}
// Timestamp
Text(
@@ -304,7 +280,6 @@ private fun MessagePreviewAndIndicatorRow(
) {
Row(
modifier = modifier.fillMaxWidth(),
- horizontalArrangement = spacedBy(28.dp)
) {
if (room.isTombstoned) {
Text(
@@ -318,6 +293,16 @@ private fun MessagePreviewAndIndicatorRow(
)
} else {
if (room.latestEvent is LatestEvent.Error) {
+ Icon(
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(16.dp),
+ imageVector = CompoundIcons.ErrorSolid(),
+ // The last message contains the error.
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
Text(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.common_message_failed_to_send),
@@ -328,6 +313,17 @@ private fun MessagePreviewAndIndicatorRow(
overflow = TextOverflow.Ellipsis,
)
} else {
+ if (room.latestEvent is LatestEvent.Sending) {
+ Icon(
+ modifier = Modifier
+ .padding(top = 2.dp)
+ .size(16.dp),
+ imageVector = CompoundIcons.Time(),
+ contentDescription = stringResource(CommonStrings.common_sending),
+ tint = ElementTheme.colors.iconTertiary,
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ }
val messagePreview = room.latestEvent.content()
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString())
Text(
@@ -341,7 +337,7 @@ private fun MessagePreviewAndIndicatorRow(
)
}
}
-
+ Spacer(modifier = Modifier.width(16.dp))
// Call and unread
Row(
modifier = Modifier
@@ -387,7 +383,7 @@ private fun InviteNameAndIndicatorRow(
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgMedium,
- text = name ?: stringResource(id = CommonStrings.common_no_room_name),
+ text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
color = ElementTheme.colors.roomListRoomName,
maxLines = 1,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
index ef5d1ffcc63..c17923fb040 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt
@@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -31,7 +32,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
-import timber.log.Timber
+import java.lang.IllegalStateException
import kotlin.time.Duration.Companion.seconds
@Inject
@@ -43,6 +44,7 @@ class RoomListDataSource(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val dateTimeObserver: DateTimeObserver,
+ private val analyticsService: AnalyticsService,
) {
init {
observeNotificationSettings()
@@ -139,10 +141,18 @@ class RoomListDataSource(
// TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed
val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 }
if (duplicates.isNotEmpty()) {
- Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind")
+ analyticsService.trackError(
+ IllegalStateException(
+ "Found duplicates in room summaries after a local UI update: $duplicates. " +
+ "This could be a race condition/caching issue of some kind"
+ )
+ )
+
+ // Remove duplicates before emitting the new values
+ _allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList())
+ } else {
+ _allRooms.emit(roomListRoomSummaries.toImmutableList())
}
-
- _allRooms.emit(roomListRoomSummaries.toImmutableList())
}
private fun buildAndCacheItem(roomSummaries: List, index: Int): RoomListRoomSummary? {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
index a7e6d173d12..e95b8df579e 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt
@@ -156,6 +156,8 @@ private fun RoomListClearFiltersButton(
.align(Alignment.Center)
.size(16.dp),
imageVector = CompoundIcons.Close(),
+ // TCHAP theme : color used when background is blue Tchap
+// tint = ElementTheme.colors.iconOnSolidPrimary,
tint = ElementTheme.iconOnSolidBlueTchap,
contentDescription = stringResource(id = R.string.screen_roomlist_clear_filters),
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
index 188f4468de3..7f58e05c7ef 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt
@@ -91,7 +91,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList {
timestamp = "14:18",
latestEvent = LatestEvent.Synced("A very very very very long message which suites on two lines"),
avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem),
- id = "!roomId:domain",
+ id = "!roomId5:domain",
),
aRoomListRoomSummary(
name = "Room#2",
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
index 20222c144dd..d8269fbc04f 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt
@@ -10,6 +10,5 @@ package io.element.android.features.home.impl.search
sealed interface RoomListSearchEvents {
data object ToggleSearchVisibility : RoomListSearchEvents
- data class QueryChanged(val query: String) : RoomListSearchEvents
data object ClearQuery : RoomListSearchEvents
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
index eef22669f4b..168dd134600 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.search
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,29 +33,24 @@ class RoomListSearchPresenter(
var isSearchActive by remember {
mutableStateOf(false)
}
- var searchQuery by remember {
- mutableStateOf("")
- }
+ val searchQuery = rememberTextFieldState()
LaunchedEffect(isSearchActive) {
dataSource.setIsActive(isSearchActive)
}
- LaunchedEffect(searchQuery) {
- dataSource.setSearchQuery(searchQuery)
+ LaunchedEffect(searchQuery.text) {
+ dataSource.setSearchQuery(searchQuery.text.toString())
}
fun handleEvent(event: RoomListSearchEvents) {
when (event) {
RoomListSearchEvents.ClearQuery -> {
- searchQuery = ""
- }
- is RoomListSearchEvents.QueryChanged -> {
- searchQuery = event.query
+ searchQuery.clearText()
}
RoomListSearchEvents.ToggleSearchVisibility -> {
isSearchActive = !isSearchActive
- searchQuery = ""
+ searchQuery.clearText()
}
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
index f361802bfae..e5da17b6fe3 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt
@@ -8,13 +8,14 @@
package io.element.android.features.home.impl.search
+import androidx.compose.foundation.text.input.TextFieldState
import io.element.android.features.home.impl.model.RoomListRoomSummary
import kotlinx.collections.immutable.ImmutableList
data class RoomListSearchState(
val showMatrixId: Boolean,
val isSearchActive: Boolean,
- val query: String,
+ val query: TextFieldState,
val results: ImmutableList,
val eventSink: (RoomListSearchEvents) -> Unit
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
index 537871511f4..b12d32393a5 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt
@@ -8,6 +8,7 @@
package io.element.android.features.home.impl.search
+import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList
@@ -34,7 +35,7 @@ fun aRoomListSearchState(
) = RoomListSearchState(
showMatrixId = false,
isSearchActive = isSearchActive,
- query = query,
+ query = TextFieldState(initialText = query),
results = results,
eventSink = eventSink,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
index 5bce36771c8..5c283e2f418 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt
@@ -18,16 +18,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
@@ -35,7 +32,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -112,23 +108,14 @@ private fun RoomListSearchContent(
},
navigationIcon = { BackButton(onClick = ::onBackButtonClick) },
title = {
- // TODO replace `state.query` with TextFieldState when it's available for M3 TextField
// The stateSaver will keep the selection state when returning to this UI
- var value by rememberSaveable(stateSaver = TextFieldValue.Saver) {
- mutableStateOf(TextFieldValue(state.query))
- }
-
val focusRequester = remember { FocusRequester() }
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
- value = value,
- singleLine = true,
- onValueChange = {
- value = it
- state.eventSink(RoomListSearchEvents.QueryChanged(it.text))
- },
+ state = state.query,
+ lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
@@ -138,20 +125,18 @@ private fun RoomListSearchContent(
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
- trailingIcon = {
- if (value.text.isNotEmpty()) {
- IconButton(onClick = {
- state.eventSink(RoomListSearchEvents.ClearQuery)
- // Clear local state too
- value = value.copy(text = "")
- }) {
+ trailingIcon = if (state.query.text.isNotEmpty()) {
+ @Composable {
+ IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_cancel)
)
}
}
- }
+ } else {
+ null
+ },
)
LaunchedEffect(Unit) {
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
index a890a61ac3c..00129235fe6 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt
@@ -15,6 +15,8 @@ import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.architecture.Presenter
+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.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.persistentListOf
@@ -27,9 +29,11 @@ import kotlinx.coroutines.flow.map
class HomeSpacesPresenter(
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
+ private val featureFlagsService: FeatureFlagService,
) : Presenter {
@Composable
override fun present(): HomeSpacesState {
+ val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false)
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
val spaceRooms by remember {
client.spaceService.spaceRoomsFlow.map { it.toImmutableList() }
@@ -48,6 +52,7 @@ class HomeSpacesPresenter(
spaceRooms = spaceRooms,
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
+ canCreateSpaces = canCreateSpaces,
eventSink = ::handleEvent,
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt
index 7dcb3702196..9bcf7131c8a 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt
@@ -18,6 +18,7 @@ data class HomeSpacesState(
val spaceRooms: ImmutableList,
val seenSpaceInvites: ImmutableSet,
val hideInvitesAvatar: Boolean,
+ val canCreateSpaces: Boolean,
val eventSink: (HomeSpacesEvents) -> Unit,
)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
index 8c03cff7ee4..c1a32a1f342 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt
@@ -30,6 +30,13 @@ open class HomeSpacesStateProvider : PreviewParameterProvider {
),
spaceRooms = aListOfSpaceRooms(),
),
+ aHomeSpacesState(
+ space = CurrentSpace.Space(
+ spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com"))
+ ),
+ spaceRooms = aListOfSpaceRooms(),
+ canCreateSpaces = false,
+ ),
)
}
@@ -38,12 +45,14 @@ internal fun aHomeSpacesState(
spaceRooms: List = aListOfSpaceRooms(),
seenSpaceInvites: Set = emptySet(),
hideInvitesAvatar: Boolean = false,
+ canCreateSpaces: Boolean = true,
eventSink: (HomeSpacesEvents) -> Unit = {},
) = HomeSpacesState(
space = space,
spaceRooms = spaceRooms.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
+ canCreateSpaces = canCreateSpaces,
eventSink = eventSink,
)
diff --git a/features/home/impl/src/main/res/values-hr/translations.xml b/features/home/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..233cac78d80
--- /dev/null
+++ b/features/home/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,55 @@
+
+
+ "Onemogućite optimizaciju baterije za ovu aplikaciju kako biste bili sigurni da ćete primati sve obavijesti."
+ "Onemogući optimizaciju"
+ "Obavijesti ne stižu?"
+ "Vaš je signal obavijesti ažuriran – jasniji je, brži i manje ometajući."
+ "Ažurirali smo vaše zvukove"
+ "Ako ste izgubili sve postojeće uređaje, oporavite svoj kriptografski identitet i povijest poruka pomoću ključa za oporavak."
+ "Postavljanje oporavka"
+ "Postavite oporavak kako biste zaštitili svoj račun"
+ "Potvrdite svoj ključ za oporavak kako biste zadržali pristup pohrani ključeva i povijesti poruka."
+ "Unesite svoj ključ za oporavak"
+ "Zaboravili ste ključ za oporavak?"
+ "Vaša pohrana ključeva nije sinkronizirana"
+ "Kako biste bili sigurni da nikada nećete propustiti važan poziv, promijenite postavke kako biste omogućili obavijesti preko cijelog zaslona kada je telefon zaključan."
+ "Poboljšajte svoje iskustvo poziva"
+ "Razgovori"
+ "Prostori"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje %1$s?"
+ "Odbij poziv"
+ "Jeste li sigurni da želite odbiti ovaj privatni razgovor s korisnikom %1$s?"
+ "Odbij razgovor"
+ "Nema pozivnica"
+ "Pozvao vas je korisnik %1$s (%2$s)"
+ "Ovaj se postupak izvodi samo jednom, hvala na čekanju."
+ "Postavljanje vašeg računa."
+ "Stvori novi razgovor ili sobu"
+ "Ukloni filtre"
+ "Započnite tako da nekome pošaljete poruku."
+ "Još nema razgovora."
+ "Favoriti"
+ "Razgovor možete dodati u favorite u postavkama razgovora.
+Zasad možete poništiti odabir filtara kako biste vidjeli ostale razgovore."
+ "Još nemate omiljenih razgovora"
+ "Pozivnice"
+ "Nemate pozivnica na čekanju."
+ "Nizak prioritet"
+ "Još nemate razgovora niskog prioriteta"
+ "Možete poništiti odabir filtara kako biste vidjeli ostale razgovore"
+ "Nemate razgovora za ovaj odabir"
+ "Osobe"
+ "Nemate još nijednu izravnu poruku"
+ "Sobe"
+ "Niste još ni u jednoj sobi"
+ "Nepročitano"
+ "Čestitamo!
+Nemate nepročitanih poruka!"
+ "Zahtjev za pridruživanje je poslan"
+ "Razgovori"
+ "Označi kao pročitano"
+ "Označi kao nepročitano"
+ "Ova je soba nadograđena"
+ "Izgleda da koristite novi uređaj. Izvršite provjeru drugim uređajem da biste pristupili svojim šifriranim porukama."
+ "Potvrdi identitet"
+
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
index 9778556dd25..582de66414e 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
@@ -47,6 +47,7 @@ class DefaultHomeEntryPointTest {
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError()
override fun navigateToCreateRoom() = lambdaError()
+ override fun navigateToCreateSpace() = lambdaError()
override fun navigateToSettings() = lambdaError()
override fun navigateToSetUpRecovery() = lambdaError()
override fun navigateToEnterRecoveryKey() = lambdaError()
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
index 0ae3ea1ff37..266c33015db 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt
@@ -22,9 +22,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
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
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
@@ -35,7 +32,6 @@ 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_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@@ -54,8 +50,6 @@ class HomePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
- private val isSpaceEnabled = FeatureFlags.Space.defaultValue(aBuildMeta())
-
@Test
fun `present - should start with no user and then load user with success`() = runTest {
val matrixClient = FakeMatrixClient(
@@ -79,7 +73,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(
MatrixUser(A_USER_ID, null, null)
@@ -91,8 +84,7 @@ class HomePresenterTest {
MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL)
)
assertThat(withUserState.showAvatarIndicator).isFalse()
- assertThat(withUserState.isSpaceFeatureEnabled).isEqualTo(isSpaceEnabled)
- assertThat(withUserState.showNavigationBar).isEqualTo(isSpaceEnabled)
+ assertThat(withUserState.showNavigationBar).isTrue()
}
}
@@ -114,23 +106,6 @@ class HomePresenterTest {
}
}
- @Test
- fun `present - space feature enabled`() = runTest {
- val presenter = createHomePresenter(
- featureFlagService = FakeFeatureFlagService(
- initialState = mapOf(FeatureFlags.Space.key to true),
- ),
- sessionStore = InMemorySessionStore(
- updateUserProfileResult = { _, _, _ -> },
- ),
- )
- presenter.test {
- skipItems(1)
- val initialState = awaitItem()
- assertThat(initialState.isSpaceFeatureEnabled).isTrue()
- }
- }
-
@Test
fun `present - show avatar indicator`() = runTest {
val indicatorService = FakeIndicatorService()
@@ -143,7 +118,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isFalse()
indicatorService.setShowRoomListTopBarIndicator(true)
@@ -168,7 +142,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId))
// No new state is coming
@@ -189,7 +162,6 @@ class HomePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- if (isSpaceEnabled) skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces))
@@ -207,16 +179,12 @@ class HomePresenterTest {
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
- featureFlagService = FakeFeatureFlagService(
- initialState = mapOf(FeatureFlags.Space.key to true),
- ),
homeSpacesPresenter = homeSpacesPresenter,
announcementService = FakeAnnouncementService(
showAnnouncementResult = {},
)
)
presenter.test {
- skipItems(1)
val initialState = awaitItem()
assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats)
assertThat(initialState.showNavigationBar).isTrue()
@@ -241,7 +209,6 @@ internal fun createHomePresenter(
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) },
indicatorService: IndicatorService = FakeIndicatorService(),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() },
sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
@@ -250,11 +217,10 @@ internal fun createHomePresenter(
syncService = syncService,
snackbarDispatcher = snackbarDispatcher,
indicatorService = indicatorService,
- logoutPresenter = { aDirectLogoutState() },
roomListPresenter = { aRoomListState() },
homeSpacesPresenter = homeSpacesPresenter,
+ logoutPresenter = { aDirectLogoutState() },
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
- featureFlagService = featureFlagService,
sessionStore = sessionStore,
announcementService = announcementService,
)
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
index 35c30af86eb..aaecd90c568 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
+import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -103,5 +104,6 @@ class RoomListDataSourceTest {
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
+ analyticsService = FakeAnalyticsService(),
)
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index a4b5bde637c..81e254626c8 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -666,6 +666,7 @@ class RoomListPresenterTest {
notificationSettingsService = client.notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = FakeDateTimeObserver(),
+ analyticsService = FakeAnalyticsService(),
),
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
index bb82d51a790..a07093aa9f1 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt
@@ -273,6 +273,7 @@ private fun AndroidComposeTestRule.setRoomL
onSetUpRecoveryClick: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(),
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
+ onCreateSpaceClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
@@ -286,6 +287,7 @@ private fun AndroidComposeTestRule.setRoomL
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,
+ onCreateSpaceClick = onCreateSpaceClick,
onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
index d82055df25b..3c39a01ef21 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt
@@ -35,7 +35,7 @@ class RoomListSearchPresenterTest {
}.test {
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
- assertThat(state.query).isEmpty()
+ assertThat(state.query.text.toString()).isEmpty()
assertThat(state.results).isEmpty()
}
}
@@ -75,10 +75,10 @@ class RoomListSearchPresenterTest {
).isEqualTo(
RoomListFilter.None
)
- state.eventSink(RoomListSearchEvents.QueryChanged("Search"))
+ state.query.edit { append("Search") }
}
awaitItem().let { state ->
- assertThat(state.query).isEqualTo("Search")
+ assertThat(state.query.text).isEqualTo("Search")
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
@@ -87,7 +87,7 @@ class RoomListSearchPresenterTest {
state.eventSink(RoomListSearchEvents.ClearQuery)
}
awaitItem().let { state ->
- assertThat(state.query).isEmpty()
+ assertThat(state.query.text.toString()).isEmpty()
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt
index c7608833ac3..43d3a8896dc 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.spaces
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.test.InMemorySeenInvitesStore
+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.test.FakeMatrixClient
import io.element.android.tests.testutils.test
@@ -23,18 +26,25 @@ class HomeSpacesPresenterTest {
val presenter = createPresenter()
presenter.test {
val state = awaitItem()
+ // canCreateSpaces is initially false
+ assertThat(state.canCreateSpaces).isFalse()
assertThat(state.space).isEqualTo(CurrentSpace.Root)
assertThat(state.spaceRooms).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.seenSpaceInvites).isEmpty()
+
+ // It'll eventually be true
+ assertThat(awaitItem().canCreateSpaces).isTrue()
}
}
private fun createPresenter(
client: MatrixClient = FakeMatrixClient(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
+ featureFlagsService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.CreateSpaces.key to true)),
) = HomeSpacesPresenter(
client = client,
seenInvitesStore = seenInvitesStore,
+ featureFlagsService = featureFlagsService,
)
}
diff --git a/features/invite/impl/src/main/res/values-hr/translations.xml b/features/invite/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..6d853dacf1d
--- /dev/null
+++ b/features/invite/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Nećete vidjeti nikakve poruke ili pozivnice za sobu od ovog korisnika"
+ "Blokiraj korisnika"
+ "Prijavite ovu sobu svom davatelju usluga računa."
+ "Navedite razlog prijave…"
+ "Odbij i blokiraj"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje %1$s?"
+ "Odbij poziv"
+ "Jeste li sigurni da želite odbiti ovaj privatni razgovor s korisnikom %1$s?"
+ "Odbij razgovor"
+ "Nema pozivnica"
+ "Pozvao vas je korisnik %1$s (%2$s)"
+ "Da, odbij i blokiraj"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje ovoj sobi? Time ćete također spriječiti da %1$s kontaktira s vama ili vas pozove u sobe."
+ "Odbij poziv i blokiraj"
+ "Odbij i blokiraj"
+
diff --git a/features/invitepeople/impl/src/main/res/values-hr/translations.xml b/features/invitepeople/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..66031c5fd7b
--- /dev/null
+++ b/features/invitepeople/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Već je član"
+ "Već je pozvan/a"
+
diff --git a/features/joinroom/impl/src/main/res/values-hr/translations.xml b/features/joinroom/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..1a8489255a2
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,34 @@
+
+
+ "Korisnik %1$s vam je zabranio pristup."
+ "Zabranjen vam je pristup"
+ "Razlog: %1$s."
+ "Otkaži zahtjev"
+ "Da, otkaži"
+ "Jeste li sigurni da želite otkazati svoj zahtjev za pridruživanje ovoj sobi?"
+ "Otkaži zahtjev za pridruživanje"
+ "Da, odbij i blokiraj"
+ "Jeste li sigurni da želite odbiti poziv za pridruživanje ovoj sobi? Time ćete također spriječiti da %1$s kontaktira s vama ili vas pozove u sobe."
+ "Odbij poziv i blokiraj"
+ "Odbij i blokiraj"
+ "Pridruživanje nije uspjelo"
+ "Morate biti pozvani da se pridružite ili možda postoje ograničenja pristupa."
+ "Zaboravi"
+ "Trebate imati pozivnicu kako biste se pridružili"
+ "Pozvao/la"
+ "Pridruži se"
+ "Možda ćete morati biti pozvani ili biti član prostora kako biste se pridružili."
+ "Pošalji zahtjev za pridruživanje"
+ "Dopuštenih znakova %1$d od %2$d"
+ "Poruka (nije obavezna)"
+ "Primit ćete pozivnicu za pridruživanje sobi ako vaš zahtjev bude prihvaćen."
+ "Zahtjev za pridruživanje je poslan"
+ "Nismo mogli prikazati pregled sobe. To bi moglo biti zbog problema s mrežom ili poslužiteljem."
+ "Nismo mogli prikazati pregled ove sobe"
+ "%1$s još ne podržava prostore. Prostorima možete pristupiti na internetu."
+ "Prostori još nisu podržani"
+ "Kliknite donji gumb i administrator sobe dobit će obavijest. Moći ćete se pridružiti razgovoru nakon što dobijete odobrenje."
+ "Morate biti član ove sobe da biste vidjeli povijest poruka."
+ "Želite li se pridružiti ovoj sobi?"
+ "Pregled nije dostupan"
+
diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt
new file mode 100644
index 00000000000..82bceb5be0d
--- /dev/null
+++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.knockrequests.api
+
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
+
+data class KnockRequestPermissions(
+ val canAccept: Boolean,
+ val canDecline: Boolean,
+ val canBan: Boolean,
+) {
+ val hasAny = canAccept || canDecline || canBan
+
+ companion object {
+ val DEFAULT = KnockRequestPermissions(
+ canAccept = false,
+ canDecline = false,
+ canBan = false,
+ )
+ }
+}
+
+fun RoomPermissions.knockRequestPermissions(): KnockRequestPermissions {
+ return KnockRequestPermissions(
+ canAccept = canOwnUserInvite(),
+ canDecline = canOwnUserKick(),
+ canBan = canOwnUserBan(),
+ )
+}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt
index 641efffaa32..f340d597b15 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt
@@ -49,7 +49,7 @@ class KnockRequestsBannerPresenter(
val shouldShowBanner by remember {
derivedStateOf {
- permissions.canHandle && knockRequests.isNotEmpty()
+ permissions.hasAny && knockRequests.isNotEmpty()
}
}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt
deleted file mode 100644
index 2ca4d4df749..00000000000
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.knockrequests.impl.data
-
-import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.powerlevels.canBan
-import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
-import io.element.android.libraries.matrix.api.room.powerlevels.canKick
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-
-data class KnockRequestPermissions(
- val canAccept: Boolean,
- val canDecline: Boolean,
- val canBan: Boolean,
-) {
- val canHandle = canAccept || canDecline || canBan
-}
-
-fun JoinedRoom.knockRequestPermissionsFlow(): Flow {
- return syncUpdateFlow.map {
- val canAccept = canInvite().getOrDefault(false)
- val canDecline = canKick().getOrDefault(false)
- val canBan = canBan().getOrDefault(false)
- KnockRequestPermissions(canAccept, canDecline, canBan)
- }
-}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
index eeba54f87d7..b51b78f1058 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt
@@ -12,10 +12,13 @@ import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.knockRequestPermissions
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
@BindingContainer
@ContributesTo(RoomScope::class)
@@ -25,7 +28,9 @@ object KnockRequestsModule {
fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService {
return KnockRequestsService(
knockRequestsFlow = room.knockRequestsFlow,
- permissionsFlow = room.knockRequestPermissionsFlow(),
+ permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms ->
+ perms.knockRequestPermissions()
+ },
isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
coroutineScope = room.roomCoroutineScope
)
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt
index 04c2f7b316d..98570e6b28e 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt
@@ -8,6 +8,7 @@
package io.element.android.features.knockrequests.impl.data
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt
index a1bb90cae89..ae770a297d3 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt
@@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list
import androidx.compose.runtime.Immutable
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
index 2c4c92a6b68..85fc0675ad0 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt
@@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.list
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
diff --git a/features/knockrequests/impl/src/main/res/values-hr/translations.xml b/features/knockrequests/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..7f8d39503f9
--- /dev/null
+++ b/features/knockrequests/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,37 @@
+
+
+ "Da, prihvati sve"
+ "Jeste li sigurni da želite prihvatiti sve zahtjeve za pridruživanje?"
+ "Prihvati sve zahtjeve"
+ "Prihvati sve"
+ "Nismo mogli prihvatiti sve zahtjeve. Želite li pokušati ponovno?"
+ "Prihvaćanje svih zahtjeva nije uspjelo"
+ "Prihvaćanje svih zahtjeva za pridruživanje"
+ "Nismo mogli prihvatiti ovaj zahtjev. Želite li pokušati ponovno?"
+ "Prihvaćanje zahtjeva nije uspjelo"
+ "Prihvaća se zahtjev za pridruživanje"
+ "Da, odbij i zabrani"
+ "Jeste li sigurni da želite odbiti i zabraniti korisnika %1$s? Taj korisnik neće moći ponovno zatražiti pristup ovoj sobi."
+ "Odbij i zabrani pristup"
+ "Odbijanje i zabrana pristupa"
+ "Da, odbij"
+ "Jeste li sigurni da želite odbiti zahtjev korisnika %1$s za pridruživanje ovoj sobi?"
+ "Odbij pristup"
+ "Odbij i zabrani"
+ "Nismo mogli odbiti ovaj zahtjev. Želite li pokušati ponovno?"
+ "Odbijanje zahtjeva nije uspjelo"
+ "Odbijanje zahtjeva za pridruživanje"
+ "Kada netko zatraži pridruživanje sobi, ovdje ćete moći vidjeti njihov zahtjev."
+ "Nema zahtjeva za pridruživanje koji su na čekanju"
+ "Učitavanje zahtjeva za pridruživanje…"
+ "Zahtjevi za pridruživanje"
+
+ - "%1$s i još %2$d želi se pridružiti ovoj sobi"
+ - "%1$s i još %2$d želi se pridružiti ovoj sobi"
+ - "%1$s i još %2$d želi se pridružiti ovoj sobi"
+
+ "Prikaži sve"
+ "Prihvati"
+ "%1$s želi se pridružiti ovoj sobi"
+ "Prikaz"
+
diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt
index 9eaa51cecb8..3161d3e81f0 100644
--- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt
+++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt
@@ -9,7 +9,7 @@
package io.element.android.features.knockrequests.impl.banner
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.test.A_USER_ID
diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt
index b0d0b68c917..7102b017736 100644
--- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt
+++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt
@@ -11,7 +11,7 @@
package io.element.android.features.knockrequests.impl.list
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions
+import io.element.android.features.knockrequests.api.KnockRequestPermissions
import io.element.android.features.knockrequests.impl.data.KnockRequestsService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
diff --git a/features/leaveroom/api/src/main/res/values-hr/translations.xml b/features/leaveroom/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..4eb9e3405ce
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "Jeste li sigurni da želite napustiti ovaj razgovor? Ovaj razgovor nije javan i nećete se moći ponovno pridružiti bez pozivnice."
+ "Jeste li sigurni da želite napustiti ovu sobu? Ovdje ste jedino vi. Ako odete, nitko se ubuduće neće moći pridružiti, pa ni vi."
+ "Jeste li sigurni da želite napustiti ovu sobu? Ova soba nije javna i nećete joj se moći ponovno pridružiti bez pozivnice."
+ "Odaberi vlasnike"
+ "Vi ste jedini vlasnik ove sobe. Morate prenijeti vlasništvo na nekog drugog prije nego što napustite sobu."
+ "Prenesi vlasništvo"
+ "Jeste li sigurni da želite napustiti sobu?"
+
diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
index c371d425c15..6455b456592 100644
--- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
+++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt
@@ -96,10 +96,7 @@ class LeaveRoomPresenter(
} else {
val hasPrivilegedCreatorRole = roomInfoFlow.value.privilegedCreatorRole
if (!hasPrivilegedCreatorRole) return false
-
- val creators = usersWithRole(RoomMember.Role.Owner(isCreator = true)).first()
- val superAdmins = usersWithRole(RoomMember.Role.Owner(isCreator = false)).first()
- val owners = creators + superAdmins
+ val owners = usersWithRole { role -> role is RoomMember.Role.Owner }.first()
return owners.size == 1 && owners.first().userId == sessionId
}
}
diff --git a/features/linknewdevice/api/build.gradle.kts b/features/linknewdevice/api/build.gradle.kts
new file mode 100644
index 00000000000..7d368f0a630
--- /dev/null
+++ b/features/linknewdevice/api/build.gradle.kts
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt
new file mode 100644
index 00000000000..061bbeb0844
--- /dev/null
+++ b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface LinkNewDeviceEntryPoint : FeatureEntryPoint {
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: Callback,
+ ): Node
+}
diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts
new file mode 100644
index 00000000000..9c1aa9e990d
--- /dev/null
+++ b/features/linknewdevice/impl/build.gradle.kts
@@ -0,0 +1,63 @@
+import extension.setupDependencyInjection
+import extension.testCommonDependencies
+
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+ id("kotlin-parcelize")
+ alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.impl"
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+setupDependencyInjection()
+
+dependencies {
+ // TODO Cleanup
+ implementation(projects.appconfig)
+ implementation(projects.features.enterprise.api)
+ implementation(projects.features.rageshake.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.testtags)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.permissions.api)
+ implementation(projects.libraries.sessionStorage.api)
+ implementation(projects.libraries.qrcode)
+ implementation(projects.libraries.oidc.api)
+ implementation(projects.libraries.uiUtils)
+ implementation(projects.libraries.wellknown.api)
+ implementation(libs.androidx.browser)
+ implementation(libs.androidx.webkit)
+ implementation(libs.serialization.json)
+ api(projects.features.linknewdevice.api)
+
+ testCommonDependencies(libs, true)
+ testImplementation(projects.features.linknewdevice.test)
+ testImplementation(projects.features.enterprise.test)
+ testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.oidc.test)
+ testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.libraries.wellknown.test)
+}
diff --git a/features/linknewdevice/impl/src/main/AndroidManifest.xml b/features/linknewdevice/impl/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..d225716fc4c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt
new file mode 100644
index 00000000000..5edc3cdfcdf
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+
+@ContributesBinding(SessionScope::class)
+class DefaultLinkNewDeviceEntryPoint : LinkNewDeviceEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: LinkNewDeviceEntryPoint.Callback,
+ ): Node {
+ return parentNode.createNode(
+ buildContext = buildContext,
+ plugins = listOf(
+ callback,
+ )
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
new file mode 100644
index 00000000000..a8a8ff14b61
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import dev.zacsweers.metro.Inject
+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.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.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
+
+@Inject
+@SingleIn(SessionScope::class)
+class LinkNewDesktopHandler(
+ private val matrixClient: MatrixClient,
+) {
+ private val sessionScope = matrixClient.sessionCoroutineScope
+ private val linkDesktopStepFlow = MutableStateFlow(
+ LinkDesktopStep.Uninitialized
+ )
+
+ val stepFlow: StateFlow
+ get() = linkDesktopStepFlow.asStateFlow()
+
+ private var currentJob: Job? = null
+ private var handler: LinkDesktopHandler? = null
+
+ fun createNewHandler() {
+ currentJob?.cancel()
+ currentJob = null
+ handler = matrixClient.createLinkDesktopHandler().getOrNull()
+ }
+
+ fun reset() {
+ currentJob?.cancel()
+ currentJob = null
+ sessionScope.launch {
+ linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
+ }
+ }
+
+ fun onScannedCode(data: ByteArray) {
+ currentJob?.cancel()
+ currentJob = null
+ val currentHandler = handler
+ if (currentHandler == null) {
+ Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
+ } else {
+ currentJob = matrixClient.sessionCoroutineScope.launch {
+ currentHandler.linkDesktopStep.onEach {
+ linkDesktopStepFlow.emit(it)
+ }.launchIn(this)
+ currentHandler.handleScannedQrCode(data)
+ }
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
new file mode 100644
index 00000000000..23c6b6ab2dc
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import android.app.Activity
+import android.os.Parcelable
+import androidx.activity.compose.LocalActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+import com.bumble.appyx.navmodel.backstack.operation.pop
+import com.bumble.appyx.navmodel.backstack.operation.push
+import com.bumble.appyx.navmodel.backstack.operation.replace
+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.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.features.linknewdevice.impl.screens.desktop.DesktopNoticeNode
+import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode
+import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType
+import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode
+import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode
+import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode
+import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
+import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
+import io.element.android.libraries.architecture.BackstackView
+import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.parcelize.Parcelize
+import timber.log.Timber
+
+private val tag = LoggerTag("LinkNewDeviceFlowNode", LoggerTags.linkNewDevice)
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class LinkNewDeviceFlowNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ @SessionCoroutineScope
+ private val sessionCoroutineScope: CoroutineScope,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+ private val linkNewDesktopHandler: LinkNewDesktopHandler,
+) : BaseFlowNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Root,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+ private val callback: LinkNewDeviceEntryPoint.Callback = callback()
+ private var activity: Activity? = null
+ private var darkTheme: Boolean = false
+
+ override fun onBuilt() {
+ super.onBuilt()
+ var linkMobileHandlerJob: Job? = null
+ var linkDesktopHandlerJob: Job? = null
+
+ lifecycle.subscribe(
+ onCreate = {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ @Suppress("AssignedValueIsNeverRead")
+ linkMobileHandlerJob = observeLinkNewMobileHandler()
+ @Suppress("AssignedValueIsNeverRead")
+ linkDesktopHandlerJob = observeLinkNewDesktopHandler()
+ },
+ onDestroy = {
+ linkMobileHandlerJob?.cancel()
+ linkDesktopHandlerJob?.cancel()
+ }
+ )
+ }
+
+ sealed interface NavTarget : Parcelable {
+ // Will display the not supported state or the device type selection
+ @Parcelize
+ data object Root : NavTarget
+
+ @Parcelize
+ data class MobileShowQrCode(
+ val data: String,
+ ) : NavTarget
+
+ @Parcelize
+ data object MobileEnterNumber : NavTarget
+
+ @Parcelize
+ data object DesktopNotice : NavTarget
+
+ @Parcelize
+ data object DesktopScanQrCode : NavTarget
+
+ @Parcelize
+ data class Error(
+ val errorScreenType: ErrorScreenType,
+ ) : NavTarget
+ }
+
+ private fun observeLinkNewMobileHandler(): Job {
+ Timber.tag(tag.value).d("startObservingLinkNewMobileHandler")
+ return linkNewMobileHandler.stepFlow
+ .onEach { linkMobileStep ->
+ Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
+ when (linkMobileStep) {
+ LinkMobileStep.Uninitialized -> Unit
+ LinkMobileStep.Done -> {
+ callback.onDone()
+ }
+ is LinkMobileStep.Error -> {
+ navigateToError(linkMobileStep.errorType)
+ }
+ is LinkMobileStep.QrReady -> {
+ // The QrCode is ready, navigate to its display
+ backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
+ }
+ is LinkMobileStep.QrScanned -> {
+ backstack.replace(NavTarget.MobileEnterNumber)
+ }
+ LinkMobileStep.Starting -> {
+ // This step is not received at the moment, so do nothing
+ }
+ LinkMobileStep.SyncingSecrets -> {
+ // LinkMobileStep.Done is not received at the moment, so consider that the flow is done here
+ callback.onDone()
+ }
+ is LinkMobileStep.WaitingForAuth -> {
+ navigateToBrowser(linkMobileStep.verificationUri)
+ }
+ }
+ }
+ .launchIn(sessionCoroutineScope)
+ }
+
+ private fun observeLinkNewDesktopHandler(): Job {
+ Timber.tag(tag.value).d("startObservingLinkNewDesktopHandler")
+ return linkNewDesktopHandler.stepFlow.onEach { linkDesktopStep ->
+ Timber.tag(tag.value).d("step: ${linkDesktopStep::class.java.simpleName}")
+ when (linkDesktopStep) {
+ LinkDesktopStep.Done -> callback.onDone()
+ is LinkDesktopStep.Error -> {
+ navigateToError(linkDesktopStep.errorType)
+ }
+ is LinkDesktopStep.EstablishingSecureChannel -> Unit
+ is LinkDesktopStep.InvalidQrCode -> {
+ // This error will be handled by the ScanQrCodeNode
+ }
+ LinkDesktopStep.Starting -> Unit
+ LinkDesktopStep.SyncingSecrets -> Unit
+ LinkDesktopStep.Uninitialized -> Unit
+ is LinkDesktopStep.WaitingForAuth -> {
+ navigateToBrowser(linkDesktopStep.verificationUri)
+ }
+ }
+ }
+ .launchIn(sessionCoroutineScope)
+ }
+
+ private fun navigateToError(errorType: ErrorType) {
+ // Map the error to an error screen
+ // TODO Update this mapping
+ val error = when (errorType) {
+ is ErrorType.DeviceIdAlreadyInUse -> ErrorScreenType.UnknownError
+ is ErrorType.InvalidCheckCode -> ErrorScreenType.InsecureChannelDetected
+ is ErrorType.MissingSecretsBackup -> ErrorScreenType.UnknownError
+ is ErrorType.NotFound -> ErrorScreenType.Expired
+ is ErrorType.UnableToCreateDevice -> ErrorScreenType.UnknownError
+ is ErrorType.Unknown -> ErrorScreenType.UnknownError
+ is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
+ }
+ // It is OK to push on backstack, since when user leaves the error screen, a new root will be set
+ backstack.push(NavTarget.Error(error))
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Root -> {
+ val callback = object : LinkNewDeviceRootNode.Callback {
+ override fun onDone() {
+ callback.onDone()
+ }
+
+ override fun linkDesktopDevice() {
+ linkNewDesktopHandler.reset()
+ backstack.push(NavTarget.DesktopNotice)
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.DesktopNotice -> {
+ val callback = object : DesktopNoticeNode.Callback {
+ override fun navigateBack() {
+ backstack.pop()
+ }
+
+ override fun navigateToQrCodeScanner() {
+ backstack.push(NavTarget.DesktopScanQrCode)
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.DesktopScanQrCode -> {
+ val callback = object : ScanQrCodeNode.Callback {
+ override fun cancel() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ NavTarget.MobileEnterNumber -> {
+ val callback = object : EnterNumberNode.Callback {
+ override fun navigateToWrongNumberError() {
+ backstack.push(NavTarget.Error(ErrorScreenType.Mismatch2Digits))
+ }
+
+ override fun navigateBack() {
+ backstack.pop()
+ }
+ }
+ createNode(buildContext, listOf(callback))
+ }
+ is NavTarget.MobileShowQrCode -> {
+ val callback = object : ShowQrCodeNode.Callback {
+ override fun navigateBack() {
+ linkNewMobileHandler.reset()
+ backstack.pop()
+ }
+ }
+ val inputs = ShowQrCodeNode.Inputs(
+ data = navTarget.data,
+ )
+ createNode(buildContext, listOf(inputs, callback))
+ }
+ is NavTarget.Error -> {
+ val callback = object : ErrorNode.Callback {
+ override fun onRetry() {
+ linkNewMobileHandler.reset()
+ linkNewDesktopHandler.reset()
+ backstack.newRoot(NavTarget.Root)
+ }
+ }
+ createNode(buildContext, listOf(callback, navTarget.errorScreenType))
+ }
+ }
+ }
+
+ private fun navigateToBrowser(url: String) {
+ activity?.openUrlInChromeCustomTab(null, darkTheme, url)
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ activity = requireNotNull(LocalActivity.current)
+ darkTheme = !ElementTheme.isLightTheme
+ DisposableEffect(Unit) {
+ onDispose {
+ activity = null
+ }
+ }
+ BackstackView()
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
new file mode 100644
index 00000000000..157d946eaa3
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import dev.zacsweers.metro.Inject
+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.LinkMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("LinkNewMobileHandler", LoggerTags.linkNewDevice)
+
+@Inject
+@SingleIn(SessionScope::class)
+class LinkNewMobileHandler(
+ private val matrixClient: MatrixClient,
+) {
+ private val sessionScope = matrixClient.sessionCoroutineScope
+ private var currentJob: Job? = null
+ private var handler: LinkMobileHandler? = null
+
+ private val linkMobileStepFlow = MutableStateFlow(
+ LinkMobileStep.Uninitialized
+ )
+
+ val stepFlow: StateFlow
+ get() = linkMobileStepFlow.asStateFlow()
+
+ fun createAndStartNewHandler() {
+ Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
+ currentJob?.cancel()
+ handler = matrixClient.createLinkMobileHandler().getOrNull()
+ handler?.let { h ->
+ currentJob = sessionScope.launch {
+ h.linkMobileStep
+ .onEach {
+ linkMobileStepFlow.emit(it)
+ }
+ .launchIn(this)
+ h.start()
+ }
+ }
+ }
+
+ fun reset() {
+ currentJob?.cancel()
+ currentJob = null
+ sessionScope.launch {
+ linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt
new file mode 100644
index 00000000000..3d94da3fc6a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+sealed interface DesktopNoticeEvent {
+ data object Continue : DesktopNoticeEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt
new file mode 100644
index 00000000000..895d02731ab
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class DesktopNoticeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: DesktopNoticePresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun navigateBack()
+ fun navigateToQrCodeScanner()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ DesktopNoticeView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ onReadyToScanClick = callback::navigateToQrCodeScanner,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt
new file mode 100644
index 00000000000..3b01725fe14
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import android.Manifest
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.permissions.api.PermissionsEvent
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+
+@Inject
+class DesktopNoticePresenter(
+ permissionsPresenterFactory: PermissionsPresenter.Factory,
+) : Presenter {
+ private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
+ private var pendingPermissionRequest by mutableStateOf(false)
+
+ @Composable
+ override fun present(): DesktopNoticeState {
+ val cameraPermissionState = cameraPermissionPresenter.present()
+ var canContinue by remember { mutableStateOf(false) }
+ LaunchedEffect(cameraPermissionState.permissionGranted) {
+ if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
+ pendingPermissionRequest = false
+ canContinue = true
+ }
+ }
+
+ fun handleEvent(event: DesktopNoticeEvent) {
+ when (event) {
+ DesktopNoticeEvent.Continue -> if (cameraPermissionState.permissionGranted) {
+ canContinue = true
+ } else {
+ pendingPermissionRequest = true
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
+ }
+ }
+ }
+
+ return DesktopNoticeState(
+ cameraPermissionState = cameraPermissionState,
+ canContinue = canContinue,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt
new file mode 100644
index 00000000000..81991b5ab2e
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import io.element.android.libraries.permissions.api.PermissionsState
+
+data class DesktopNoticeState(
+ val cameraPermissionState: PermissionsState,
+ val canContinue: Boolean,
+ val eventSink: (DesktopNoticeEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
new file mode 100644
index 00000000000..194bd6fafce
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2024, 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import android.Manifest
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.permissions.api.PermissionsState
+import io.element.android.libraries.permissions.api.aPermissionsState
+
+open class DesktopNoticeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aDesktopNoticeState(),
+ aDesktopNoticeState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)),
+ )
+}
+
+fun aDesktopNoticeState(
+ cameraPermissionState: PermissionsState = aPermissionsState(
+ showDialog = false,
+ permission = Manifest.permission.CAMERA,
+ ),
+ canContinue: Boolean = false,
+ eventSink: (DesktopNoticeEvent) -> Unit = {},
+) = DesktopNoticeState(
+ cameraPermissionState = cameraPermissionState,
+ canContinue = canContinue,
+ eventSink = eventSink
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
new file mode 100644
index 00000000000..c7f0bb61e75
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.permissions.api.PermissionsView
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * Desktop notice screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23618
+ */
+@Composable
+fun DesktopNoticeView(
+ state: DesktopNoticeState,
+ onBackClick: () -> Unit,
+ onReadyToScanClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val latestOnReadyToScanClick by rememberUpdatedState(onReadyToScanClick)
+ LaunchedEffect(state.canContinue) {
+ if (state.canContinue) {
+ latestOnReadyToScanClick()
+ }
+ }
+
+ val appName = LocalBuildMeta.current.applicationName
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_desktop_title, appName),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ modifier = modifier,
+ buttons = {
+ Button(
+ text = stringResource(R.string.screen_link_new_device_desktop_submit),
+ onClick = { state.eventSink(DesktopNoticeEvent.Continue) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ) {
+ Column(
+ Modifier.fillMaxWidth()
+ ) {
+ Spacer(modifier = Modifier.height(40.dp))
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)),
+ annotatedTextWithBold(
+ text = stringResource(
+ id = R.string.screen_link_new_device_mobile_step2,
+ stringResource(R.string.screen_link_new_device_mobile_step2_action),
+ ),
+ boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action)
+ ),
+ AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)),
+ )
+ )
+ }
+ }
+
+ PermissionsView(
+ title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title),
+ content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, appName),
+ icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) },
+ state = state.cameraPermissionState,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun DesktopNoticeViewPreview(
+ @PreviewParameter(DesktopNoticeStateProvider::class) state: DesktopNoticeState,
+) = ElementPreview {
+ DesktopNoticeView(
+ state = state,
+ onBackClick = { },
+ onReadyToScanClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
new file mode 100644
index 00000000000..70fd3b49a47
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ErrorNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext = buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onRetry()
+ }
+
+ private val callback: Callback = callback()
+ private val errorScreenType = inputs()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ErrorView(
+ modifier = modifier,
+ errorScreenType = errorScreenType,
+ onRetry = callback::onRetry,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt
new file mode 100644
index 00000000000..b92a19ef8ae
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import android.os.Parcelable
+import androidx.compose.runtime.Immutable
+import io.element.android.libraries.architecture.NodeInputs
+import kotlinx.parcelize.Parcelize
+
+@Immutable
+sealed interface ErrorScreenType : NodeInputs, Parcelable {
+ @Parcelize
+ data object Cancelled : ErrorScreenType
+
+ @Parcelize
+ data object Expired : ErrorScreenType
+
+ @Parcelize
+ data object Mismatch2Digits : ErrorScreenType
+
+ @Parcelize
+ data object InsecureChannelDetected : ErrorScreenType
+
+ @Parcelize
+ data object Declined : ErrorScreenType
+
+ @Parcelize
+ data object ProtocolNotSupported : ErrorScreenType
+
+ @Parcelize
+ data object SlidingSyncNotAvailable : ErrorScreenType
+
+ @Parcelize
+ data object UnknownError : ErrorScreenType
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
new file mode 100644
index 00000000000..7fd699101be
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class ErrorScreenTypeProvider : PreviewParameterProvider {
+ override val values: Sequence = sequenceOf(
+ ErrorScreenType.Cancelled,
+ ErrorScreenType.Declined,
+ ErrorScreenType.Expired,
+ ErrorScreenType.ProtocolNotSupported,
+ ErrorScreenType.Mismatch2Digits,
+ ErrorScreenType.InsecureChannelDetected,
+ ErrorScreenType.SlidingSyncNotAvailable,
+ ErrorScreenType.UnknownError,
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
new file mode 100644
index 00000000000..3a77f19f499
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun ErrorView(
+ errorScreenType: ErrorScreenType,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val appName = LocalBuildMeta.current.applicationName
+ BackHandler(onBack = onRetry)
+ FlowStepPage(
+ modifier = modifier,
+ iconStyle = BigIcon.Style.AlertSolid,
+ title = titleText(errorScreenType, appName),
+ subTitle = subtitleText(errorScreenType, appName),
+ content = { Content(errorScreenType) },
+ buttons = { Buttons(onRetry) },
+ )
+}
+
+@Composable
+private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title)
+ ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title)
+ ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title)
+ ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title)
+ ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName)
+ is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong)
+}
+
+@Composable
+private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle)
+ ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName)
+ ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_subtitle)
+ ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description)
+ ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName)
+ is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description)
+}
+
+@Composable
+private fun ColumnScope.InsecureChannelDetectedError() {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ textAlign = TextAlign.Center,
+ )
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)),
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)),
+ AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)),
+ )
+ )
+}
+
+@Composable
+private fun Content(errorScreenType: ErrorScreenType) {
+ when (errorScreenType) {
+ ErrorScreenType.InsecureChannelDetected -> {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ InsecureChannelDetectedError()
+ }
+ }
+ else -> Unit
+ }
+}
+
+@Composable
+private fun Buttons(onRetry: () -> Unit) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_start_over),
+ onClick = onRetry
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class) errorScreenType: ErrorScreenType) {
+ ElementPreview {
+ ErrorView(
+ errorScreenType = errorScreenType,
+ onRetry = {},
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt
new file mode 100644
index 00000000000..b61cc82a22b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+object Config {
+ const val VERIFICATION_CODE_LENGTH = 2
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt
new file mode 100644
index 00000000000..267b5086697
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+sealed interface EnterNumberEvent {
+ data class UpdateNumber(val number: String) : EnterNumberEvent
+ data object Continue : EnterNumberEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt
new file mode 100644
index 00000000000..08d8cd02bd9
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+interface EnterNumberNavigator {
+ fun navigateToWrongNumberError()
+}
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class EnterNumberNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ presenterFactory: EnterNumberPresenter.Factory,
+) : Node(buildContext, plugins = plugins), EnterNumberNavigator {
+ private val presenter = presenterFactory.create(this)
+
+ interface Callback : Plugin {
+ fun navigateToWrongNumberError()
+ fun navigateBack()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ EnterNumberView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ )
+ }
+
+ override fun navigateToWrongNumberError() {
+ callback.navigateToWrongNumberError()
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt
new file mode 100644
index 00000000000..74b5b6b2948
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedFactory
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.api.logs.LoggerTags
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val tag = LoggerTag("EnterNumberPresenter", LoggerTags.linkNewDevice)
+
+@AssistedInject
+class EnterNumberPresenter(
+ @Assisted private val navigator: EnterNumberNavigator,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(navigator: EnterNumberNavigator): EnterNumberPresenter
+ }
+
+ @Composable
+ override fun present(): EnterNumberState {
+ val coroutineScope = rememberCoroutineScope()
+ var number by remember { mutableStateOf("") }
+ var sendingCode by remember>> { mutableStateOf(AsyncAction.Uninitialized) }
+
+ // Observe the flow to react on ErrorType.InvalidCheckCode
+ val linkMobileStep by linkNewMobileHandler.stepFlow.collectAsState()
+
+ var checkCodeSender: CheckCodeSender? by remember { mutableStateOf(null) }
+
+ LaunchedEffect(linkMobileStep) {
+ when (val step = linkMobileStep) {
+ is LinkMobileStep.QrScanned -> {
+ checkCodeSender = step.checkCodeSender
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: EnterNumberEvent) {
+ when (event) {
+ is EnterNumberEvent.UpdateNumber -> {
+ sendingCode = AsyncAction.Uninitialized
+ // Keep only digits as a safety measure
+ number = event.number.filter { it.isDigit() }
+ }
+ EnterNumberEvent.Continue -> coroutineScope.launch {
+ // Get the current code sender
+ val sender = checkCodeSender
+ if (sender == null) {
+ Timber.tag(tag.value).e("No check code sender available")
+ sendingCode = AsyncAction.Failure(IllegalStateException("No check code sender available"))
+ } else {
+ sendingCode = AsyncAction.Loading
+ val uByte = number.toUByte()
+ val isValid = sender.validate(uByte)
+ if (isValid) {
+ sender.send(uByte)
+ .fold(
+ onSuccess = {
+ Timber.tag(tag.value).d("Code sent successfully")
+ // Keep loading, do not set sendingCode to AsyncAction.Success(Unit)
+ },
+ onFailure = {
+ Timber.tag(tag.value).e(it, "Failed to send number code")
+ sendingCode = AsyncAction.Failure(it)
+ }
+ )
+ } else {
+ // Navigate to the error state
+ navigator.navigateToWrongNumberError()
+ }
+ }
+ }
+ }
+ }
+
+ return EnterNumberState(
+ number = number,
+ sendingCode = sendingCode,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt
new file mode 100644
index 00000000000..b8f66018dd4
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import io.element.android.features.linknewdevice.impl.screens.number.model.Number
+import io.element.android.libraries.architecture.AsyncAction
+
+data class EnterNumberState(
+ val number: String,
+ val sendingCode: AsyncAction,
+ val eventSink: (EnterNumberEvent) -> Unit,
+) {
+ val numberEntry = Number.createEmpty(Config.VERIFICATION_CODE_LENGTH).fillWith(number)
+ val isContinueButtonEnabled: Boolean
+ get() = numberEntry.isComplete() && !sendingCode.isLoading()
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
new file mode 100644
index 00000000000..126bfeee52d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+
+open class EnterNumberStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aEnterNumberState(),
+ aEnterNumberState(number = "1"),
+ aEnterNumberState(number = "12"),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Loading),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(ErrorType.InvalidCheckCode("Invalid"))),
+ aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(Exception("Failed to send code"))),
+ )
+}
+
+fun aEnterNumberState(
+ number: String = "",
+ sendingCode: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (EnterNumberEvent) -> Unit = {},
+) = EnterNumberState(
+ number = number,
+ sendingCode = sendingCode,
+ eventSink = eventSink,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
new file mode 100644
index 00000000000..240a3143aff
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.features.linknewdevice.impl.screens.number.component.NumberTextField
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Form to enter number:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2076-81604
+ */
+@Composable
+fun EnterNumberView(
+ state: EnterNumberState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_enter_number_title),
+ subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ modifier = modifier,
+ isScrollable = true,
+ buttons = {
+ Button(
+ text = stringResource(CommonStrings.action_continue),
+ onClick = { state.eventSink(EnterNumberEvent.Continue) },
+ enabled = state.isContinueButtonEnabled,
+ showProgress = state.sendingCode.isLoading(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = stringResource(R.string.screen_link_new_device_enter_number_notice),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = ElementTheme.colors.textPrimary,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ NumberTextField(
+ number = state.numberEntry,
+ onValueChange = { state.eventSink(EnterNumberEvent.UpdateNumber(it)) },
+ onDone = {
+ if (state.isContinueButtonEnabled) {
+ state.eventSink(EnterNumberEvent.Continue)
+ }
+ },
+ )
+ val failure = state.sendingCode.errorOrNull()
+ if (failure != null) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Icon(
+ modifier = Modifier.size(14.dp),
+ imageVector = CompoundIcons.ErrorSolid(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ )
+ val errorMessage = when (failure) {
+ is ErrorType.InvalidCheckCode -> stringResource(R.string.screen_link_new_device_enter_number_error_numbers_do_not_match)
+ else -> failure.message ?: stringResource(CommonStrings.error_unknown)
+ }
+ Text(
+ text = errorMessage,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textCriticalPrimary,
+ )
+ }
+ }
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun EnterNumberViewPreview(
+ @PreviewParameter(EnterNumberStateProvider::class) state: EnterNumberState,
+) = ElementPreview {
+ EnterNumberView(
+ state = state,
+ onBackClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt
new file mode 100644
index 00000000000..568a729ed6e
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.features.linknewdevice.impl.screens.number.model.Digit
+import io.element.android.features.linknewdevice.impl.screens.number.model.Number
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import kotlinx.coroutines.delay
+
+@Composable
+fun NumberTextField(
+ number: Number,
+ onValueChange: (String) -> Unit,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused = LocalInspectionMode.current || interactionSource.collectIsFocusedAsState().value
+ BasicTextField(
+ modifier = modifier,
+ value = number.toText(),
+ onValueChange = {
+ onValueChange(it)
+ },
+ interactionSource = interactionSource,
+ maxLines = 1,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ onDone()
+ }
+ ),
+ decorationBox = {
+ NumberRow(
+ number = number,
+ hasFocus = isFocused,
+ )
+ }
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun NumberRow(
+ number: Number,
+ hasFocus: Boolean,
+) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ val length = number.length()
+ number.digits.forEachIndexed { index, digit ->
+ DigitView(
+ digit = digit,
+ isCurrent = index == length,
+ drawCursor = hasFocus,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DigitView(
+ digit: Digit,
+ isCurrent: Boolean,
+ drawCursor: Boolean,
+) {
+ val shape = RoundedCornerShape(4.dp)
+ val appearanceModifier = when (digit) {
+ Digit.Empty -> {
+ val color = if (isCurrent) {
+ ElementTheme.colors.textPrimary
+ } else {
+ ElementTheme.colors.borderInteractiveSecondary
+ }
+ Modifier.border(1.dp, color, shape)
+ }
+ is Digit.Filled -> {
+ Modifier.background(ElementTheme.colors.bgActionSecondaryPressed, shape)
+ }
+ }
+ Box(
+ modifier = Modifier
+ .size(42.dp, 56.dp)
+ .then(appearanceModifier),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (digit is Digit.Filled) {
+ Text(
+ text = digit.value.toString(),
+ style = ElementTheme.typography.fontHeadingLgBold,
+ color = ElementTheme.colors.textPrimary,
+ )
+ } else if (drawCursor && isCurrent) {
+ // Draw a blinking cursor
+ BlinkingCursor()
+ }
+ }
+}
+
+@Composable
+private fun BlinkingCursor() {
+ var isCursorVisible by remember { mutableStateOf(true) }
+ LaunchedEffect(isCursorVisible) {
+ delay(500)
+ // Toggle cursor visibility
+ isCursorVisible = !isCursorVisible
+ }
+ if (isCursorVisible) {
+ Spacer(
+ modifier = Modifier
+ .size(2.dp, 24.dp)
+ .offset(x = (-5).dp)
+ .background(ElementTheme.colors.textPrimary, RoundedCornerShape(1.dp))
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun NumberTextFieldPreview() {
+ ElementPreview {
+ val number = Number.createEmpty(4).fillWith("12")
+ NumberTextField(
+ number = number,
+ onValueChange = {},
+ onDone = {},
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt
new file mode 100644
index 00000000000..b8565ea6ca0
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number.model
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface Digit {
+ data object Empty : Digit
+ data class Filled(val value: Char) : Digit
+
+ fun toText(): String {
+ return when (this) {
+ is Empty -> ""
+ is Filled -> value.toString()
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt
new file mode 100644
index 00000000000..be60f27f76f
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number.model
+
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+data class Number(
+ val digits: ImmutableList,
+) {
+ companion object {
+ fun createEmpty(size: Int): Number {
+ val digits = List(size) { Digit.Empty }
+ return Number(
+ digits = digits.toImmutableList()
+ )
+ }
+ }
+
+ val size = digits.size
+
+ /**
+ * Fill the first digits with the given text.
+ * Can't be more than the size of the NumberEntry
+ * Keep the Empty digits at the end
+ * @return the new NumberEntry
+ */
+ fun fillWith(text: String): Number {
+ val newDigits = MutableList(size) { Digit.Empty }
+ text.forEachIndexed { index, char ->
+ if (index < size && char.isDigit()) {
+ newDigits[index] = Digit.Filled(char)
+ }
+ }
+ return copy(digits = newDigits.toImmutableList())
+ }
+
+ fun length(): Int {
+ return digits.count { it is Digit.Filled }
+ }
+
+ fun toText(): String {
+ return digits.joinToString("") {
+ it.toText()
+ }
+ }
+
+ fun isComplete(): Boolean {
+ return digits.all { it is Digit.Filled }
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
new file mode 100644
index 00000000000..a884c3e97f5
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.architecture.inputs
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ShowQrCodeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext, plugins = plugins) {
+ class Inputs(
+ val data: String,
+ ) : NodeInputs
+
+ interface Callback : Plugin {
+ fun navigateBack()
+ }
+
+ private val inputs: Inputs = inputs()
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ ShowQrCodeView(
+ data = inputs.data,
+ modifier = modifier,
+ onBackClick = callback::navigateBack,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
new file mode 100644
index 00000000000..501415f621c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.LocalBuildMeta
+import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
+import io.element.android.libraries.qrcode.QrCodeImage
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * QrCode display screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
+ */
+@Composable
+fun ShowQrCodeView(
+ data: String,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val appName = LocalBuildMeta.current.applicationName
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = stringResource(R.string.screen_link_new_device_mobile_title, appName),
+ iconStyle = BigIcon.Style.Default(CompoundIcons.TakePhotoSolid()),
+ modifier = modifier,
+ ) {
+ Column(
+ Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ QrCodeImage(
+ data = data,
+ modifier = Modifier
+ .size(220.dp)
+ )
+ Spacer(modifier = Modifier.height(32.dp))
+ NumberedListOrganism(
+ modifier = Modifier.fillMaxSize(),
+ items = persistentListOf(
+ AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step1, appName)),
+ annotatedTextWithBold(
+ text = stringResource(
+ id = R.string.screen_link_new_device_mobile_step2,
+ stringResource(R.string.screen_link_new_device_mobile_step2_action),
+ ),
+ boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action)
+ ),
+ AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step3)),
+ )
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ShowQrCodeViewPreview() = ElementPreview {
+ ShowQrCodeView(
+ data = "DATA",
+ onBackClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
new file mode 100644
index 00000000000..8ce6af90b01
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+sealed interface LinkNewDeviceRootEvent {
+ data object LinkMobileDevice : LinkNewDeviceRootEvent
+ data object CloseDialog : LinkNewDeviceRootEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
new file mode 100644
index 00000000000..f43ffa64df8
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class LinkNewDeviceRootNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: LinkNewDeviceRootPresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onDone()
+ fun linkDesktopDevice()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ LinkNewDeviceRootView(
+ state = state,
+ modifier = modifier,
+ onBackClick = callback::onDone,
+ onLinkDesktopDeviceClick = callback::linkDesktopDevice,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
new file mode 100644
index 00000000000..a17ed88fd3e
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import kotlinx.coroutines.launch
+
+@Inject
+class LinkNewDeviceRootPresenter(
+ private val matrixClient: MatrixClient,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @Composable
+ override fun present(): LinkNewDeviceRootState {
+ val coroutineScope = rememberCoroutineScope()
+ var isSupported by remember { mutableStateOf>(AsyncData.Uninitialized) }
+ var qrCodeData by remember { mutableStateOf>(AsyncData.Uninitialized) }
+
+ LaunchedEffect(Unit) {
+ matrixClient.canLinkNewDevice().fold(
+ onSuccess = { supported ->
+ isSupported = AsyncData.Success(supported)
+ },
+ onFailure = {
+ isSupported = AsyncData.Failure(it)
+ }
+ )
+ }
+
+ val step by linkNewMobileHandler.stepFlow.collectAsState()
+
+ LaunchedEffect(step) {
+ when (val finalStep = step) {
+ is LinkMobileStep.Uninitialized -> {
+ qrCodeData = AsyncData.Uninitialized
+ }
+ is LinkMobileStep.QrReady -> {
+ qrCodeData = AsyncData.Success(Unit)
+ }
+ is LinkMobileStep.Error -> {
+ qrCodeData = AsyncData.Failure(finalStep.errorType)
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: LinkNewDeviceRootEvent) {
+ when (event) {
+ LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
+ qrCodeData = AsyncData.Loading()
+ // Wait for the QrCode to be ready
+ linkNewMobileHandler.reset()
+ linkNewMobileHandler.createAndStartNewHandler()
+ }
+ LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
+ linkNewMobileHandler.reset()
+ }
+ }
+ }
+
+ return LinkNewDeviceRootState(
+ isSupported = isSupported,
+ qrCodeData = qrCodeData,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt
new file mode 100644
index 00000000000..6cf6694b2a7
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import io.element.android.libraries.architecture.AsyncData
+
+data class LinkNewDeviceRootState(
+ val isSupported: AsyncData,
+ val qrCodeData: AsyncData,
+ val eventSink: (LinkNewDeviceRootEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
new file mode 100644
index 00000000000..f1bb7ad4555
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
+
+open class LinkNewDeviceRootStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLinkNewDeviceRootState(),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Success(true)),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Success(false)),
+ aLinkNewDeviceRootState(isSupported = AsyncData.Failure(Exception("Should not happen"))),
+ aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ qrCodeData = AsyncData.Loading(),
+ ),
+ aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ qrCodeData = AsyncData.Failure(ErrorType.NotFound("The rendezvous session was not found and might have expired")),
+ ),
+ )
+}
+
+fun aLinkNewDeviceRootState(
+ isSupported: AsyncData = AsyncData.Uninitialized,
+ qrCodeData: AsyncData = AsyncData.Uninitialized,
+ eventSink: (LinkNewDeviceRootEvent) -> Unit = { },
+) = LinkNewDeviceRootState(
+ isSupported = isSupported,
+ qrCodeData = qrCodeData,
+ eventSink = eventSink,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
new file mode 100644
index 00000000000..d9249d3e89b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.IconSource
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Device selection screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23616
+ * Not supported screen:
+ * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2186-70004
+ */
+@Composable
+fun LinkNewDeviceRootView(
+ state: LinkNewDeviceRootState,
+ onBackClick: () -> Unit,
+ onLinkDesktopDeviceClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
+ Triple(
+ stringResource(R.string.screen_link_new_device_error_not_supported_title),
+ stringResource(R.string.screen_link_new_device_error_not_supported_subtitle),
+ BigIcon.Style.AlertSolid
+ )
+ } else {
+ Triple(
+ stringResource(R.string.screen_link_new_device_root_title),
+ null,
+ BigIcon.Style.Default(CompoundIcons.Devices())
+ )
+ }
+ FlowStepPage(
+ onBackClick = onBackClick,
+ title = title,
+ subTitle = subtitle,
+ iconStyle = iconStyle,
+ buttons = {
+ when (state.isSupported) {
+ is AsyncData.Uninitialized,
+ is AsyncData.Loading -> {
+ LoadingButtonAtom()
+ }
+ is AsyncData.Failure -> {
+ Text(
+ text = stringResource(id = CommonStrings.error_unknown),
+ color = ElementTheme.colors.textCriticalPrimary,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ textAlign = TextAlign.Center,
+ )
+ Button(
+ onClick = onBackClick,
+ text = stringResource(CommonStrings.action_dismiss),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ is AsyncData.Success -> {
+ if (state.isSupported.data) {
+ when (state.qrCodeData) {
+ AsyncData.Uninitialized,
+ is AsyncData.Failure -> {
+ Button(
+ onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
+ text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
+ )
+ Button(
+ onClick = onLinkDesktopDeviceClick,
+ text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
+ )
+ }
+ is AsyncData.Loading,
+ is AsyncData.Success -> {
+ Button(
+ onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
+ text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
+ showProgress = true,
+ enabled = false,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = onLinkDesktopDeviceClick,
+ text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
+ modifier = Modifier.fillMaxWidth(),
+ enabled = false,
+ leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
+ )
+ }
+ }
+ } else {
+ Button(
+ onClick = onBackClick,
+ text = stringResource(CommonStrings.action_dismiss),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+ },
+ modifier = modifier,
+ )
+
+ val failure = state.qrCodeData.errorOrNull()
+ if (failure != null) {
+ ErrorDialog(
+ content = failure.message ?: stringResource(CommonStrings.error_unknown),
+ onSubmit = { state.eventSink(LinkNewDeviceRootEvent.CloseDialog) },
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LinkNewDeviceRootViewPreview(
+ @PreviewParameter(LinkNewDeviceRootStateProvider::class) state: LinkNewDeviceRootState
+) = ElementPreview {
+ LinkNewDeviceRootView(
+ state = state,
+ onBackClick = { },
+ onLinkDesktopDeviceClick = { },
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt
new file mode 100644
index 00000000000..c17a28649c9
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+sealed interface ScanQrCodeEvent {
+ data class QrCodeScanned(val data: ByteArray) : ScanQrCodeEvent
+ data object TryAgain : ScanQrCodeEvent
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt
new file mode 100644
index 00000000000..ff4f88f9ae1
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedInject
+import io.element.android.annotations.ContributesNode
+import io.element.android.libraries.architecture.callback
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class ScanQrCodeNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: ScanQrCodePresenter,
+) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun cancel()
+ }
+
+ private val callback: Callback = callback()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ ScanQrCodeView(
+ state = state,
+ onBackClick = callback::cancel,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt
new file mode 100644
index 00000000000..5f0131a8dfe
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import kotlinx.coroutines.launch
+
+@Inject
+class ScanQrCodePresenter(
+ private val linkNewDesktopHandler: LinkNewDesktopHandler,
+) : Presenter {
+ @Composable
+ override fun present(): ScanQrCodeState {
+ val coroutineScope = rememberCoroutineScope()
+ var scanAction: AsyncAction by remember { mutableStateOf(AsyncAction.Loading) }
+
+ // Observe the flow to react on LinkDesktopStep.InvalidQrCode
+ val linkDesktopStep by linkNewDesktopHandler.stepFlow.collectAsState()
+
+ LaunchedEffect(Unit) {
+ linkNewDesktopHandler.createNewHandler()
+ }
+
+ LaunchedEffect(linkDesktopStep) {
+ when (val step = linkDesktopStep) {
+ is LinkDesktopStep.InvalidQrCode -> {
+ scanAction = AsyncAction.Failure(Exception(step.error))
+ }
+ else -> Unit
+ }
+ }
+
+ fun handleEvent(event: ScanQrCodeEvent) {
+ when (event) {
+ ScanQrCodeEvent.TryAgain -> {
+ scanAction = AsyncAction.Loading
+ }
+ is ScanQrCodeEvent.QrCodeScanned -> coroutineScope.launch {
+ // In this case the scanning will stop and a loader will be shown
+ scanAction = AsyncAction.Success(Unit)
+ try {
+ linkNewDesktopHandler.onScannedCode(event.data)
+ } catch (e: Exception) {
+ // Should not happen as errors are handled through the LinkDesktopStep flow
+ scanAction = AsyncAction.Failure(e)
+ }
+ }
+ }
+ }
+
+ return ScanQrCodeState(
+ scanAction = scanAction,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt
new file mode 100644
index 00000000000..6cb03638364
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class ScanQrCodeState(
+ val scanAction: AsyncAction,
+ val eventSink: (ScanQrCodeEvent) -> Unit,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
new file mode 100644
index 00000000000..40e7df8884f
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncAction
+
+open class ScanQrCodeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aScanQrCodeState(),
+ aScanQrCodeState(scanAction = AsyncAction.Loading),
+ aScanQrCodeState(scanAction = AsyncAction.Success(Unit)),
+ aScanQrCodeState(scanAction = AsyncAction.Failure(Exception("Scan failed"))),
+ )
+}
+
+fun aScanQrCodeState(
+ scanAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (ScanQrCodeEvent) -> Unit = {},
+) = ScanQrCodeState(
+ scanAction = scanAction,
+ eventSink = eventSink
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
new file mode 100644
index 00000000000..937aee08b4b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt
@@ -0,0 +1,174 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.progressSemantics
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
+import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.modifiers.cornerBorder
+import io.element.android.libraries.designsystem.modifiers.squareSize
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Button
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.qrcode.QrCodeCameraView
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun ScanQrCodeView(
+ state: ScanQrCodeState,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FlowStepPage(
+ modifier = modifier,
+ onBackClick = onBackClick,
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ title = stringResource(R.string.screen_link_new_device_desktop_scanning_title),
+ content = { Content(state = state) },
+ buttons = { Buttons(state = state) }
+ )
+}
+
+@Composable
+private fun Content(
+ state: ScanQrCodeState,
+) {
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ val modifier = if (constraints.maxWidth > constraints.maxHeight) {
+ Modifier.fillMaxHeight()
+ } else {
+ Modifier.fillMaxWidth()
+ }.then(
+ Modifier
+ .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp)
+ .squareSize()
+ .cornerBorder(
+ strokeWidth = 4.dp,
+ color = ElementTheme.colors.textPrimary,
+ cornerSizeDp = 42.dp,
+ )
+ )
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ QrCodeCameraView(
+ modifier = Modifier.fillMaxSize(),
+ onScanQrCode = { state.eventSink.invoke(ScanQrCodeEvent.QrCodeScanned(it)) },
+ isScanning = state.scanAction.isLoading(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ColumnScope.Buttons(
+ state: ScanQrCodeState,
+) {
+ Column(Modifier.heightIn(min = 130.dp)) {
+ when (state.scanAction) {
+ is AsyncAction.Failure -> {
+ Button(
+ text = stringResource(id = CommonStrings.action_try_again),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp),
+ onClick = {
+ state.eventSink.invoke(ScanQrCodeEvent.TryAgain)
+ }
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.ErrorSolid(),
+ tint = ElementTheme.colors.iconCriticalPrimary,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle),
+ textAlign = TextAlign.Center,
+ color = ElementTheme.colors.textCriticalPrimary,
+ style = ElementTheme.typography.fontBodySmMedium,
+ )
+ }
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_description),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ )
+ }
+ }
+ is AsyncAction.Success -> {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .progressSemantics()
+ .size(20.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+ AsyncAction.Loading,
+ AsyncAction.Uninitialized,
+ is AsyncAction.Confirming -> Unit
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStateProvider::class) state: ScanQrCodeState) = ElementPreview {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = {},
+ )
+}
diff --git a/features/linknewdevice/impl/src/main/res/values-be/translations.xml b/features/linknewdevice/impl/src/main/res/values-be/translations.xml
new file mode 100644
index 00000000000..16372fa6e4c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-be/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Сканіраваць QR-код"
+ "Адсканіруйце QR-код з дапамогай гэтай прылады"
+ "Гатовы да сканіравання"
+ "Ваш правайдар уліковага запісу не падтрымлівае %1$s."
+ "%1$s не падтрымліваецца"
+ "QR-код не падтрымліваецца"
+ "Уваход быў адменены на іншай прыладзе."
+ "Запыт на ўваход скасаваны"
+ "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз."
+ "Уваход у сістэму не быў завершаны своечасова"
+ "Выберыце %1$s"
+ "Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх."
+ "Што зараз?"
+ "Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема"
+ "Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi."
+ "Калі гэта не дапамагло, увайдзіце ўручную"
+ "Злучэнне небяспечнае"
+ "Уваход быў адменены на іншай прыладзе."
+ "Запыт на ўваход скасаваны"
+ "Уваход на іншай прыладзе быў адхілены."
+ "Уваход адхілены"
+ "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз."
+ "Уваход у сістэму не быў завершаны своечасова"
+ "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода.
+
+Паспрабуйце ўвайсці ў сістэму ўручную або адсканіруйце QR-код з дапамогай іншай прылады."
+ "QR-код не падтрымліваецца"
+ "Ваш правайдар уліковага запісу не падтрымлівае %1$s."
+ "%1$s не падтрымліваецца"
+ "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."
+ "Паўтарыць спробу"
+ "Няправільны QR-код"
+ "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."
+ "Дазвольце доступ да камеры для сканіравання QR-кода"
+ "Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-bg/translations.xml b/features/linknewdevice/impl/src/main/res/values-bg/translations.xml
new file mode 100644
index 00000000000..e8a8ea95d36
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-bg/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Повторен опит"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-cs/translations.xml b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 00000000000..4b8f230d552
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Naskenujte QR kód"
+ "Otevřete %1$s na notebooku nebo stolním počítači"
+ "Naskenujte QR kód pomocí tohoto zařízení"
+ "Připraveno ke skenování"
+ "Otevřete %1$s na stolním počítači a získejte QR kód"
+ "Čísla se neshodují"
+ "Zadejte dvoumístný kód"
+ "Tím ověříte, zda je připojení k druhému zařízení bezpečné."
+ "Zadejte číslo zobrazené na druhém zařízení"
+ "Váš poskytovatel účtu nepodporuje %1$s."
+ "%1$s není podporováno"
+ "Poskytovatel vašeho účtu nepodporuje přihlašování do nového zařízení pomocí QR kódu."
+ "QR kód není podporován"
+ "Přihlášení bylo na druhém zařízení zrušeno."
+ "Žádost o přihlášení zrušena"
+ "Platnost přihlášení vypršela. Zkuste to prosím znovu."
+ "Přihlášení nebylo dokončeno včas"
+ "Otevřete %1$s na druhém zařízení"
+ "Vybrat %1$s"
+ "„Přihlásit se pomocí QR kódu“"
+ "Naskenujte zde zobrazený QR kód pomocí jiného zařízení"
+ "Otevřete %1$s na druhém zařízení"
+ "Stolní počítač"
+ "Načítání QR kódu…"
+ "Mobilní zařízení"
+ "Jaký typ zařízení chcete propojit?"
+ "Zkuste to prosím znovu a ujistěte se, že jste zadali dvoumístný kód správně. Pokud se čísla stále neshodují, kontaktujte poskytovatele účtu."
+ "Čísla se neshodují"
+ "K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat."
+ "Co teď?"
+ "Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí"
+ "Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi"
+ "Pokud to nefunguje, přihlaste se ručně"
+ "Připojení není zabezpečené"
+ "Přihlášení bylo na druhém zařízení zrušeno."
+ "Žádost o přihlášení zrušena"
+ "Přihlášení bylo na druhém zařízení odmítnuto."
+ "Přihlášení odmítnuto"
+ "Nemusíte dělat nic jiného."
+ "Vaše další zařízení je již přihlášeno"
+ "Platnost přihlášení vypršela. Zkuste to prosím znovu."
+ "Přihlášení nebylo dokončeno včas"
+ "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu.
+
+Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízení."
+ "QR kód není podporován"
+ "Váš poskytovatel účtu nepodporuje %1$s."
+ "%1$s není podporováno"
+ "Použijte QR kód zobrazený na druhém zařízení."
+ "Zkusit znovu"
+ "Špatný QR kód"
+ "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."
+ "Povolte přístup k fotoaparátu a naskenujte QR kód"
+ "Vyskytla se neočekávaná chyba. Prosím zkuste to znovu."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-cy/translations.xml b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
new file mode 100644
index 00000000000..b26aed52efc
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Sganiwch y cod QR"
+ "Sganiwch y cod QR gyda\'r ddyfais hon"
+ "Yn barod i sganio"
+ "Nid yw darparwr eich cyfrif yn cefnogi %1$s."
+ "%1$s heb ei gefnogi"
+ "Nid yw\'r cod QR yn cael ei gefnogi"
+ "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall."
+ "Cais mewngofnodi wedi\'i ddiddymu"
+ "Mewngofnodi wedi dod i ben. Ceisiwch eto."
+ "Heb gwblhau\'r mewngofnodi mewn pryd"
+ "Dewiswch %1$s"
+ "Nid oedd modd gwneud cysylltiad diogel â\'r ddyfais newydd. Mae eich dyfeisiau presennol yn dal yn ddiogel a does dim angen i chi boeni amdanyn nhw."
+ "Beth nawr?"
+ "Ceisiwch fewngofnodi eto gyda chod QR rhag ofn bod hyn yn broblem rhwydwaith"
+ "Os ydych chi\'n dod ar draws yr un broblem, rhowch gynnig ar rwydwaith wifi gwahanol neu defnyddiwch eich data symudol yn lle wifi"
+ "Os nad yw hynny\'n gweithio, mewngofnodwch â llaw"
+ "Nid yw\'r cysylltiad yn ddiogel"
+ "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall."
+ "Cais mewngofnodi wedi\'i ddiddymu"
+ "Cafodd y mewngofnodi ar y ddyfais arall ei wrthod."
+ "Gwrthodwyd y mewngofnodi"
+ "Mewngofnodi wedi dod i ben. Ceisiwch eto."
+ "Heb gwblhau\'r mewngofnodi mewn pryd"
+ "Nid yw eich dyfais arall yn cefnogi mewngofnodi i %s gyda chod QR.
+
+Ceisiwch fewngofnodi â llaw, neu sganiwch y cod QR gyda dyfais arall."
+ "Nid yw\'r cod QR yn cael ei gefnogi"
+ "Nid yw darparwr eich cyfrif yn cefnogi %1$s."
+ "%1$s heb ei gefnogi"
+ "Defnyddiwch y cod QR sy\'n cael ei ddangos ar y ddyfais arall."
+ "Ceisiwch eto"
+ "Cod QR anghywir"
+ "Mae angen i chi roi caniatâd i %1$s ddefnyddio camera eich dyfais er mwyn parhau."
+ "Caniatáu mynediad camera i sganio\'r cod QR"
+ "Digwyddodd gwall annisgwyl. Ceisiwch eto."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-da/translations.xml b/features/linknewdevice/impl/src/main/res/values-da/translations.xml
new file mode 100644
index 00000000000..a31175c1d5d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-da/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Scan QR-koden"
+ "Scan QR-koden med denne enhed"
+ "Klar til at scanne"
+ "Din kontoudbyder understøtter ikke %1$s."
+ "%1$s understøttes ikke"
+ "QR-kode understøttes ikke"
+ "Login blev annulleret på den anden enhed."
+ "Anmodning om login annulleret"
+ "Login er udløbet. Prøv venligst igen."
+ "Login blev ikke afsluttet i tide"
+ "Vælg %1$s"
+ "Der kunne ikke oprettes en sikker forbindelse til den nye enhed. Dine eksisterende enheder er stadig sikre, og du behøver ikke bekymre dig om dem."
+ "Hvad nu?"
+ "Prøv at logge ind igen med en QR-kode, hvis dette skyldtes et netværksproblem"
+ "Hvis du støder på det samme problem, kan du prøve et andet wifi-netværk eller bruge dine mobildata i stedet for wifi"
+ "Hvis det ikke virker, skal du logge ind manuelt"
+ "Forbindelsen er ikke sikker"
+ "Login blev annulleret på den anden enhed."
+ "Anmodning om login annulleret"
+ "Login blev afvist på den anden enhed."
+ "Login afvist"
+ "Login er udløbet. Prøv venligst igen."
+ "Login blev ikke afsluttet i tide"
+ "Din anden enhed understøtter ikke at logge ind på %s med en QR-kode.
+
+Prøv at logge ind manuelt, eller scan QR-koden med en anden enhed."
+ "QR-kode understøttes ikke"
+ "Din kontoudbyder understøtter ikke %1$s."
+ "%1$s understøttes ikke"
+ "Brug den QR-kode, der bliver vist på den anden enhed."
+ "Prøv igen"
+ "Forkert QR-kode"
+ "Du skal give tilladelse til at %1$s kan benytte enhedens kamera, for at fortsætte."
+ "Tillad kameraadgang for at scanne QR-koden"
+ "Der opstod en uventet fejl. Prøv venligst igen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-de/translations.xml b/features/linknewdevice/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 00000000000..b8ad8b80efc
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "QR-Code scannen"
+ "Öffne %1$s auf einem Laptop oder Desktop-Computer"
+ "Scanne den QR-Code mit diesem Gerät"
+ "Bereit zum Scannen"
+ "Öffne %1$s auf einem Desktop-Computer, um den QR-Code zu erhalten"
+ "Die Zahlen stimmen nicht überein"
+ "Gib den 2-stelligen Code ein"
+ "Dadurch wird überprüft, ob die Verbindung zu deinem anderen Gerät sicher ist."
+ "Gib die Nummer ein, die auf deinem anderen Gerät angezeigt wird"
+ "Dein Kontoanbieter unterstützt %1$s nicht."
+ "%1$s wird nicht unterstützt"
+ "Dein Kontoanbieter unterstützt die Anmeldung auf einem neuen Gerät mit einem QR-Code nicht."
+ "QR-Code wird nicht unterstützt"
+ "Die Anmeldung wurde auf dem anderen Gerät abgebrochen."
+ "Anmeldeanfrage abgebrochen"
+ "Die Anmeldung ist abgelaufen. Bitte versuche es erneut."
+ "Die Anmeldung wurde nicht rechtzeitig abgeschlossen"
+ "Öffne %1$s auf dem anderen Gerät"
+ "Wähle %1$s"
+ "„Mit QR-Code anmelden”"
+ "Scanne den hier gezeigten QR-Code mit dem anderen Gerät."
+ "Öffne %1$s auf dem anderen Gerät"
+ "Desktop-Computer"
+ "QR-Code wird geladen…"
+ "Mobilgerät"
+ "Welchen Gerätetyp möchtest du verknüpfen?"
+ "Versuch\' es bitte noch mal und stell sicher, dass du den zweistelligen Code richtig eingegeben hast. Wenn die Zahlen immer noch nicht übereinstimmen, wende dich an deinen Kontoanbieter."
+ "Die Zahlen stimmen nicht überein"
+ "Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden."
+ "Und jetzt?"
+ "Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war."
+ "Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."
+ "Wenn das nicht funktioniert, melde dich manuell an"
+ "Die Verbindung ist nicht sicher"
+ "Die Anmeldung wurde auf dem anderen Gerät abgebrochen."
+ "Anmeldeanfrage abgebrochen"
+ "Die Anmeldung auf dem anderen Gerät wurde abgelehnt."
+ "Anmelden abgelehnt"
+ "Du musst nichts weiter tun."
+ "Dein anderes Gerät ist schon angemeldet."
+ "Die Anmeldung ist abgelaufen. Bitte versuche es erneut."
+ "Die Anmeldung wurde nicht rechtzeitig abgeschlossen"
+ "Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht.
+
+Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Gerät."
+ "QR-Code wird nicht unterstützt"
+ "Dein Kontoanbieter unterstützt %1$s nicht."
+ "%1$s wird nicht unterstützt"
+ "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."
+ "Erneut versuchen"
+ "Falscher QR-Code"
+ "Du musst %1$s die Berechtigung erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."
+ "Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"
+ "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-el/translations.xml b/features/linknewdevice/impl/src/main/res/values-el/translations.xml
new file mode 100644
index 00000000000..2133bb352c9
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-el/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Σάρωση κωδικού QR"
+ "Σάρωσε τον κωδικό QR με αυτήν τη συσκευή"
+ "Έτοιμο για σάρωση"
+ "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s."
+ "Το %1$s δεν υποστηρίζεται"
+ "Ο κωδικός QR δεν υποστηρίζεται"
+ "Η σύνδεση ακυρώθηκε στην άλλη συσκευή."
+ "Το αίτημα σύνδεσης ακυρώθηκε"
+ "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά."
+ "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως"
+ "Επιλογή %1$s"
+ "Δεν ήταν δυνατή η πραγματοποίηση ασφαλούς σύνδεσης στη νέα συσκευή. Οι υπάρχουσες συσκευές σας εξακολουθούν να είναι ασφαλείς και δεν χρειάζεται να ανησυχείς για αυτές."
+ "Τί είναι πάλι;"
+ "Δοκίμασε να συνδεθείς ξανά με έναν κωδικό QR σε περίπτωση που ήταν πρόβλημα του δικτύου"
+ "Εάν αντιμετωπίσεις το ίδιο πρόβλημα, δοκίμασε ένα διαφορετικό δίκτυο wifi ή χρησιμοποίησε τα δεδομένα του κινητού σου αντί για wifi"
+ "Εάν δεν λειτουργήσει, συνδέσου χειροκίνητα"
+ "Η σύνδεση δεν είναι ασφαλής"
+ "Η σύνδεση ακυρώθηκε στην άλλη συσκευή."
+ "Το αίτημα σύνδεσης ακυρώθηκε"
+ "Η σύνδεση απορρίφθηκε στην άλλη συσκευή."
+ "Η σύνδεση απορρίφθηκε"
+ "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά."
+ "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως"
+ "Η άλλη σου συσκευή δεν υποστηρίζει σύνδεση στο %s με κωδικό QR.
+
+Δοκίμασε να συνδεθείς χειροκίνητα ή σάρωσε τον κωδικό QR με άλλη συσκευή."
+ "Ο κωδικός QR δεν υποστηρίζεται"
+ "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s."
+ "Το %1$s δεν υποστηρίζεται"
+ "Χρησιμοποίησε τον κωδικό QR που εμφανίζεται στην άλλη συσκευή."
+ "Προσπάθησε ξανά"
+ "Λάθος κωδικός QR"
+ "Πρέπει να δώσεις άδεια για %1$s για να χρησιμοποιήσεις την κάμερα της συσκευής σου και να συνεχίσεις."
+ "Επέτρεψε την πρόσβαση της κάμερας για σάρωση του κωδικού QR"
+ "Παρουσιάστηκε ένα απροσδόκητο σφάλμα. Παρακαλώ προσπάθησε ξανά."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml b/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml
new file mode 100644
index 00000000000..6395ce9e540
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "If you encounter the same problem, try a different Wi-Fi network or use your mobile data instead of Wi-Fi"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-es/translations.xml b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 00000000000..032813a2c4a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Escanea el código QR"
+ "Escanea el código QR con este dispositivo"
+ "Listo para escanear"
+ "Tu proveedor de cuentas no es compatible con %1$s."
+ "%1$s no admitido"
+ "Código QR no admitido"
+ "El inicio de sesión se canceló en el otro dispositivo."
+ "Solicitud de inicio de sesión cancelada"
+ "El inicio de sesión ha caducado. Inténtalo de nuevo."
+ "El inicio de sesión no se completó a tiempo"
+ "Selecciona %1$s"
+ "No se pudo establecer una conexión segura con el nuevo dispositivo. Tus dispositivos actuales siguen siendo seguros y no tienes que preocuparte por ellos."
+ "¿Y ahora qué?"
+ "Intenta iniciar sesión de nuevo con un código QR en caso de que se trate de un problema de red"
+ "Si te encuentras con el mismo problema, prueba con una red wifi diferente o usa tus datos móviles en lugar de wifi"
+ "Si eso no funciona, inicia sesión manualmente"
+ "La conexión no es segura"
+ "El inicio de sesión se canceló en el otro dispositivo."
+ "Solicitud de inicio de sesión cancelada"
+ "El inicio de sesión se rechazó en el otro dispositivo."
+ "Inicio de sesión rechazado"
+ "El inicio de sesión ha caducado. Inténtalo de nuevo."
+ "El inicio de sesión no se completó a tiempo"
+ "Tu otro dispositivo no admite el inicio de sesión en %s con un código QR.
+
+Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo."
+ "Código QR no admitido"
+ "Tu proveedor de cuentas no es compatible con %1$s."
+ "%1$s no admitido"
+ "Usa el código QR que se muestra en el otro dispositivo."
+ "Intentar de nuevo"
+ "Código QR incorrecto"
+ "Tienes que dar permiso a %1$s para que utilice la cámara de tu dispositivo y así poder continuar."
+ "Permite el acceso a la cámara para escanear el código QR"
+ "Se ha producido un error inesperado. Vuelve a intentarlo."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-et/translations.xml b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
new file mode 100644
index 00000000000..6aa1398e0a2
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Skaneeri QR-koodi"
+ "Ava %1$s kas oma süle- või lauaarvutis"
+ "Skaneeri QR-koodi selle seadmega"
+ "Skaneerimiseks valmis"
+ "QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"
+ "Numbrid ei klapi"
+ "Sisesta kahekohaline kood"
+ "Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."
+ "Sisesta teises seadmes kuvatud number"
+ "Sinu teenusepakkuja ei toeta rakendust %1$s."
+ "%1$s pole toetatud"
+ "Sinu kasutajakonto teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil."
+ "QR-kood pole toetatud"
+ "Sisselogimine katkestati teises seadmes."
+ "Sisselogimispäring on tühistatud"
+ "Sisselogimine aegus. Palun proovi uuesti."
+ "Sisselogimine jäi etteantud aja jooksul tegemata"
+ "Ava %1$s teises seadmes"
+ "Vali %1$s"
+ "„Logi sisse QR-koodiga“"
+ "Skaneeri siin näidatud QR-koodi teise seadmega"
+ "Ava %1$s teises seadmes"
+ "Lauaarvuti"
+ "Laadin QR-koodi…"
+ "Nutiseade"
+ "Mis tüüpi seadet soovid siduda?"
+ "Palun proovi uuesti ja veendu, et sisestasid kahekohalise koodi õigesti. Kui numbrid ikka ei klapi, võta ühendust oma kasutajakonto teenusepakkujaga."
+ "Numbrid ei klapi"
+ "Turvalise ühenduse loomine uue seadmega ei õnnestunud. Sinu olemasolevad seadmed on jätkuvalt turvatud ja sa ei pea nende pärast muretsema."
+ "Mida järgmiseks teeme?"
+ "Kui see juhtumisi oli võrguühenduse viga, siis proovi uuesti QR-koodiga sisse logida"
+ "Kui sama probleem kordub, siis kasuta mõnda muud WiFi- või mobiilset andmedsideühendust"
+ "Kui see ka ei aita, siis logi sisse käsitsi"
+ "Ühendus pole turvaline"
+ "Sisselogimine katkestati teises seadmes."
+ "Sisselogimispäring on tühistatud"
+ "Sisselogimisest on teises seadmes keeldutud."
+ "Sisselogimisest on keeldutud"
+ "Sa ei pea enam midagi muud tegema."
+ "Sinu muu seade on juba sisse logitud"
+ "Sisselogimine aegus. Palun proovi uuesti."
+ "Sisselogimine jäi etteantud aja jooksul tegemata"
+ "Sinu teine seade ei toeta %s sisselogimist QR-koodiga.
+
+Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega."
+ "QR-kood pole toetatud"
+ "Sinu teenusepakkuja ei toeta rakendust %1$s."
+ "%1$s pole toetatud"
+ "Kasuta teises seadmes näidatavat QR-koodi"
+ "Proovi uuesti"
+ "Vale QR-kood"
+ "Jätkamiseks pead lubama, et %1$s saab kasutada sinu nutiseadme kaamerat"
+ "QR-koodi lugemiseks luba kaamerat kasutada"
+ "Tekkis ootamatu viga. Palun proovi uuesti."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-eu/translations.xml b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
new file mode 100644
index 00000000000..06cc0fd8571
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
@@ -0,0 +1,36 @@
+
+
+ "Eskaneatu QR kodea"
+ "Eskaneatu QR kodea gailu honekin"
+ "Eskaneatzeko prest"
+ "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria."
+ "%1$s ez da bateragarria"
+ "QR kodea ez da bateragarria"
+ "Saioa hasteko eskaera bertan behera utzi da beste gailuan"
+ "Saioa hasteko eskaera bertan behera utzi da"
+ "Saio-hasiera iraungi da. Saiatu berriro."
+ "Saio-hasiera ez da garaiz gauzatu."
+ "Hautatu %1$s"
+ "Ezin izan da konexio segururik ezarri gailu berriarekin. Lehendik dauden gailuak seguru daude oraindik ere eta ez duzu haietaz kezkatu beharrik."
+ "Orain zer?"
+ "Saiatu berriro QR kodearekin saioa hasten sare-arazo bat izan bada"
+ "Horrek ez badu funtzionatzen, hasi saioa eskuz"
+ "Konexioa ez da segurua"
+ "Saioa hasteko eskaera bertan behera utzi da beste gailuan"
+ "Saioa hasteko eskaera bertan behera utzi da"
+ "Saioa hasteari uko egin zaio beste dispositiboan."
+ "Saio-hasiera ukatu da"
+ "Saio-hasiera iraungi da. Saiatu berriro."
+ "Saio-hasiera ez da garaiz gauzatu."
+ "Beste gailua ez da bateragarria QR kodeak erabiliz %s(e)n saioa hastearekin.
+
+Saiatu saioa eskuz hasten, edo eskaneatu QR kodea beste gailu batean."
+ "QR kodea ez da bateragarria"
+ "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria."
+ "%1$s ez da bateragarria"
+ "Erabili beste gailuan agertzen den QR kodea."
+ "Saiatu berriro"
+ "QR kode okerra"
+ "Baimendu kameraren sarbidea QR kodea eskaneatzeko"
+ "Ustekabeko errore bat gertatu da. Saiatu berriro."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
new file mode 100644
index 00000000000..804fa653ade
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
@@ -0,0 +1,36 @@
+
+
+ "پویش کد پاس"
+ "پویش کد پاس با این افزاره"
+ "آمادهٔ پویش"
+ "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمیکند."
+ "%1$s پشتیبانی نمیشود"
+ "کد پاس پشتیبانی نمیشود"
+ "ورود روی افزارهٔ دیگر لغو شد."
+ "درخواست ورد لغو شد"
+ "ورود منقضی شد. لطفاً دوباره تلاش کنید."
+ "ورود در زمان معیّن کامل نشد"
+ "گزینش %1$s"
+ "نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزارههای موجودتان هنوز امنند و نیازی نیست نگرانشان باشید."
+ "اکنون چه؟"
+ "ورود دستی در صورت کار نکردنش"
+ "اتّصال ناامن"
+ "ورود روی افزارهٔ دیگر لغو شد."
+ "درخواست ورد لغو شد"
+ "ورود به دست افزارهٔ دیگر رد شد."
+ "ورود رد شد"
+ "ورود منقضی شد. لطفاً دوباره تلاش کنید."
+ "ورود در زمان معیّن کامل نشد"
+ "افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمیکند.
+
+آزمودن ورود دستی یا پویش کد پاس با افزارهای دیگر."
+ "کد پاس پشتیبانی نمیشود"
+ "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمیکند."
+ "%1$s پشتیبانی نمیشود"
+ "استفاده از کد پاس نشان داده روی افزارهٔ دیگر."
+ "تلاش دوباره"
+ "کد پاس اشتباه"
+ "برای ادامه باید اجازهٔ استفادهٔ %1$s از دوربین افزارهتان را بدهید."
+ "اجازهٔ دسترسی دوربین برای پویش کد پاس"
+ "خطایی غیرمنتظره رخ داد. لطفاً دوباره تلاش کنید."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
new file mode 100644
index 00000000000..3ed954a842a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skannaa QR-koodi"
+ "Skannaa QR-koodi tällä laitteella"
+ "Valmis skannaamaan"
+ "Palveluntarjoajasi ei tue %1$s -sovellusta"
+ "%1$s -sovellusta ei tueta"
+ "QR-koodia ei tueta"
+ "Kirjautuminen peruutettiin toisella laitteella."
+ "Kirjautumispyyntö peruutettu"
+ "Kirjautuminen vanhentui. Yritä uudelleen."
+ "Kirjautumista ei suoritettu ajoissa"
+ "Valitse %1$s"
+ "Turvallista yhteyttä uuteen laitteeseen ei voitu muodostaa. Olemassa olevat laitteesi ovat edelleen turvassa, eikä sinun tarvitse huolehtia niistä."
+ "Mitä nyt?"
+ "Yritä kirjautua sisään uudelleen QR-koodilla, jos kyseessä oli verkko-ongelma"
+ "Jos kohtaat saman ongelman, kokeile toista wifi-verkkoa tai käytä mobiilidataa wifi-yhteyden sijaan"
+ "Jos tämä ei auta, kirjaudu sisään manuaalisesti"
+ "Yhteys ei ole turvallinen"
+ "Kirjautuminen peruutettiin toisella laitteella."
+ "Kirjautumispyyntö peruutettu"
+ "Kirjautuminen hylättiin toisella laitteella."
+ "Kirjautuminen hylätty"
+ "Kirjautuminen vanhentui. Yritä uudelleen."
+ "Kirjautumista ei suoritettu ajoissa"
+ "Toinen laitteesi ei tue kirjautumista %s -sovellukseen QR-koodilla.
+
+Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella."
+ "QR-koodia ei tueta"
+ "Palveluntarjoajasi ei tue %1$s -sovellusta"
+ "%1$s -sovellusta ei tueta"
+ "Käytä toisessa laitteessa näkyvää QR-koodia."
+ "Yritä uudelleen"
+ "Väärä QR-koodi"
+ "Jatkaaksesi sinun on annettava lupa %1$s -sovellukselle käyttää laitteesi kameraa."
+ "Salli lupa kameraan QR-koodin skannaamiseksi"
+ "Tapahtui odottamaton virhe. Yritä uudelleen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 00000000000..0c91dca7a1c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,55 @@
+
+
+ "Scannez le code QR"
+ "Ouvrir %1$s sur un ordinateur"
+ "Scanner le code QR avec cet appareil"
+ "Prêt à scanner"
+ "Ouvrir %1$s sur un ordinateur pour obtenir le code QR"
+ "Les nombres ne correspondent pas"
+ "Saisissez le code à 2 chiffres"
+ "Cela permettra de vérifier que la connexion à votre autre appareil est sécurisée."
+ "Saisir le nombre affiché sur votre autre appareil"
+ "Votre fournisseur de compte ne supporte pas %1$s."
+ "%1$s n’est pas supporté"
+ "Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à l’aide d’un code QR."
+ "Code QR non supporté"
+ "La connexion a été annulée sur l’autre appareil."
+ "Demande de connexion annulée"
+ "Connexion expirée. Veuillez essayer à nouveau."
+ "La connexion a pris trop de temps."
+ "Ouvrez %1$s sur l’autre appareil"
+ "Choisissez %1$s"
+ "« Se connecter avec un code QR »"
+ "Scannez ce code QR avec l’autre appareil."
+ "Ouvrez %1$s sur l’autre appareil"
+ "Ordinateur de bureau"
+ "Chargement du code QR…"
+ "Appareil mobile"
+ "Quel type d’appareil souhaitez-vous associer ?"
+ "Veuillez réessayer et assurez-vous d’avoir saisi correctement le code à 2 chiffres. Si les chiffres ne correspondent toujours pas, veuillez contacter votre fournisseur de compte."
+ "Les nombres ne correspondent pas"
+ "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier."
+ "Et maintenant ?"
+ "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau"
+ "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi"
+ "Si cela ne fonctionne pas, connectez-vous manuellement"
+ "La connexion n’est pas sécurisée"
+ "La connexion a été annulée sur l’autre appareil."
+ "Demande de connexion annulée"
+ "La connexion a été refusée sur l’autre appareil."
+ "Connexion refusée"
+ "Vous n’avez rien d’autre à faire."
+ "Votre autre appareil est déjà connecté"
+ "Connexion expirée. Veuillez essayer à nouveau."
+ "La connexion a pris trop de temps."
+ "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil."
+ "Code QR non supporté"
+ "Votre fournisseur de compte ne supporte pas %1$s."
+ "%1$s n’est pas supporté"
+ "Scannez le code QR affiché sur l’autre appareil."
+ "Essayer à nouveau"
+ "Code QR erroné"
+ "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."
+ "Autoriser l’usage de la caméra pour scanner le code QR"
+ "Une erreur inattendue s’est produite. Veuillez réessayer."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..20c194ef934
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Skeniraj QR kod"
+ "Otvorite %1$s na prijenosnom ili stolnom računalu"
+ "Skenirajte QR kod ovim uređajem"
+ "Spremno za skeniranje"
+ "Otvorite %1$s na stolnom računalu kako biste dobili QR kod"
+ "Brojevi se ne podudaraju"
+ "Unesite dvoznamenkasti kod"
+ "Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."
+ "Unesite broj prikazan na vašem drugom uređaju"
+ "Vaš davatelj usluga računa ne podržava %1$s ."
+ "%1$s nije podržan"
+ "Vaš davatelj usluga računa ne podržava prijavu na novi uređaj pomoću QR koda."
+ "QR kod nije podržan"
+ "Prijava je otkazana na drugom uređaju."
+ "Zahtjev za prijavu je otkazan"
+ "Prijava je istekla. Pokušajte ponovno."
+ "Prijava nije dovršena na vrijeme"
+ "Otvorite %1$s na drugom uređaju"
+ "Odaberite %1$s"
+ "“Prijavi se pomoću QR koda”"
+ "Skenirajte ovdje prikazani QR kod drugim uređajem"
+ "Otvorite %1$s na drugom uređaju"
+ "Stolno računalo"
+ "Učitavanje QR koda…"
+ "Mobilni uređaj"
+ "Koju vrstu uređaja želite povezati?"
+ "Pokušajte ponovno i provjerite jeste li ispravno unijeli dvoznamenkasti kod. Ako se brojevi i dalje ne podudaraju, obratite se davatelju usluge računa."
+ "Brojevi se ne podudaraju"
+ "Nije moguće uspostaviti sigurnu vezu s novim uređajem. Vaši postojeći uređaji i dalje su sigurni i ne morate se brinuti zbog njih."
+ "Što sad?"
+ "Pokušajte se ponovno prijaviti pomoću QR koda u slučaju da se radilo o problemu s mrežom"
+ "Ako se problem ponovi, pokušajte s drugom Wi-Fi mrežom ili mobilnim podatcima umjesto Wi-Fi-ja."
+ "Ako to ne uspije, prijavite se ručno"
+ "Veza nije sigurna"
+ "Prijava je otkazana na drugom uređaju."
+ "Zahtjev za prijavu je otkazan"
+ "Prijava je odbijena na drugom uređaju."
+ "Prijava je odbijena"
+ "Ne morate ništa drugo napraviti."
+ "Vaš drugi uređaj već je prijavljen"
+ "Prijava je istekla. Pokušajte ponovno."
+ "Prijava nije dovršena na vrijeme"
+ "Vaš drugi uređaj ne podržava prijavu na %s pomoću QR koda.
+
+Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem."
+ "QR kod nije podržan"
+ "Vaš davatelj usluga računa ne podržava %1$s ."
+ "%1$s nije podržan"
+ "Upotrijebite QR kod prikazan na drugom uređaju."
+ "Pokušajte ponovno"
+ "Pogrešan QR kod"
+ "Za nastavak morate dati dopuštenje za %1$s da biste se mogli služiti kamerom svog uređaja."
+ "Dopustite pristup kameri kako biste mogli skenirati QR kod"
+ "Došlo je do neočekivane pogreške. Pokušajte ponovno."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
new file mode 100644
index 00000000000..b06d7e2fd6d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Olvassa be a QR-kódot"
+ "Olvassa be a QR-kódot ezzel az eszközzel"
+ "Készen áll a beolvasásra"
+ "A fiókszolgáltatója nem támogatja az %1$s-et."
+ "Az %1$s nem támogatott"
+ "A QR-kód nem támogatott"
+ "A bejelentkezést megszakították a másik eszközön."
+ "Bejelentkezési kérés törölve"
+ "A bejelentkezés lejárt. Próbálja újra."
+ "A bejelentkezés nem fejeződött be időben"
+ "Válassza ezt: %1$s"
+ "Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."
+ "Most mi lesz?"
+ "Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."
+ "Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"
+ "Ha ez nem működik, jelentkezzen be kézileg"
+ "A kapcsolat nem biztonságos"
+ "A bejelentkezést megszakították a másik eszközön."
+ "Bejelentkezési kérés törölve"
+ "A bejelentkezést elutasították a másik eszközön."
+ "A bejelentkezés elutasítva"
+ "A bejelentkezés lejárt. Próbálja újra."
+ "A bejelentkezés nem fejeződött be időben"
+ "A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe.
+
+Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel."
+ "A QR-kód nem támogatott"
+ "A fiókszolgáltatója nem támogatja az %1$s-et."
+ "Az %1$s nem támogatott"
+ "Használja a másik eszközön látható QR-kódot."
+ "Próbálja újra"
+ "Hibás QR-kód"
+ "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."
+ "Engedélyezze a kamera elérését a QR-kód beolvasásához"
+ "Váratlan hiba történt. Próbálja meg újra."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-in/translations.xml b/features/linknewdevice/impl/src/main/res/values-in/translations.xml
new file mode 100644
index 00000000000..20badba9ba1
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-in/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Pindai kode QR"
+ "Pindai kode QR dengan perangkat ini"
+ "Siap untuk memindai"
+ "Penyedia akun Anda tidak mendukung %1$s."
+ "%1$s tidak didukung"
+ "Kode QR tidak didukung"
+ "Proses masuk dibatalkan di perangkat lain."
+ "Permintaan masuk dibatalkan"
+ "Masa masuk kedaluwarsa. Silakan coba lagi."
+ "Proses masuk tidak selesai tepat waktu"
+ "Pilih %1$s"
+ "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."
+ "Apa sekarang?"
+ "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"
+ "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"
+ "Jika tidak berhasil, masuk secara manual"
+ "Koneksi tidak aman"
+ "Proses masuk dibatalkan di perangkat lain."
+ "Permintaan masuk dibatalkan"
+ "Proses masuk ditolak di perangkat lain."
+ "Proses masuk ditolak"
+ "Masa masuk kedaluwarsa. Silakan coba lagi."
+ "Proses masuk tidak selesai tepat waktu"
+ "Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR.
+
+Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."
+ "Kode QR tidak didukung"
+ "Penyedia akun Anda tidak mendukung %1$s."
+ "%1$s tidak didukung"
+ "Gunakan kode QR yang ditampilkan di perangkat lain."
+ "Coba lagi"
+ "Kode QR salah"
+ "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."
+ "Izinkan akses kamera untuk memindai kode QR"
+ "Terjadi kesalahan tak terduga. Silakan coba lagi."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-it/translations.xml b/features/linknewdevice/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 00000000000..0cf548c19bd
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Scansiona il codice QR"
+ "Scansiona il codice QR con questo dispositivo"
+ "Pronto per la scansione"
+ "Il tuo fornitore di account non supporta %1$s."
+ "%1$s non supportato"
+ "Codice QR non supportato"
+ "L\'accesso è stato annullato sull\'altro dispositivo."
+ "Richiesta di accesso annullata"
+ "L\'accesso è scaduto. Riprova."
+ "L\'accesso non è stato completato in tempo"
+ "Seleziona %1$s"
+ "Non è stato possibile stabilire una connessione sicura con il nuovo dispositivo. I tuoi dispositivi esistenti sono ancora al sicuro e non devi preoccuparti di loro."
+ "E adesso?"
+ "Prova ad accedere di nuovo con un codice QR nel caso si sia verificato un problema di rete."
+ "Se riscontri lo stesso problema, prova con un altra rete wifi o usa i dati mobili al posto del wifi."
+ "Se il problema persiste, accedi manualmente"
+ "La connessione non è sicura"
+ "L\'accesso è stato annullato sull\'altro dispositivo."
+ "Richiesta di accesso annullata"
+ "L\'accesso è stato rifiutato sull\'altro dispositivo."
+ "Accesso rifiutato"
+ "L\'accesso è scaduto. Riprova."
+ "L\'accesso non è stato completato in tempo"
+ "L\'altro dispositivo non supporta l\'accesso a %s con un codice QR.
+
+Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo."
+ "Codice QR non supportato"
+ "Il tuo fornitore di account non supporta %1$s."
+ "%1$s non supportato"
+ "Usa il codice QR mostrato sull\'altro dispositivo."
+ "Riprova"
+ "Codice QR sbagliato"
+ "Per continuare, è necessario fornire l\'autorizzazione a %1$s per utilizzare la fotocamera del dispositivo."
+ "Consenti l\'accesso alla fotocamera per la scansione del codice QR"
+ "Si è verificato un errore inatteso. Riprova."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ka/translations.xml b/features/linknewdevice/impl/src/main/res/values-ka/translations.xml
new file mode 100644
index 00000000000..f378e585ceb
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ka/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "ხელახლა ცდა"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ko/translations.xml b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
new file mode 100644
index 00000000000..6af49fd3cf6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "QR 코드를 스캔하세요"
+ "이 기기로 QR 코드를 스캔하세요."
+ "스캔 준비 완료"
+ "귀하의 계정 제공자는 지원하지 않습니다 %1$s ."
+ "%1$s 지원되지 않습니다"
+ "QR 코드는 지원되지 않습니다"
+ "다른 기기에서 로그인이 취소되었습니다."
+ "로그인 요청이 취소되었습니다"
+ "로그인이 만료되었습니다. 다시 시도해 주세요."
+ "로그인 시간이 초과되었습니다."
+ "선택 %1$s"
+ "새 장치에 안전하게 연결할 수 없습니다. 기존 장치는 여전히 안전하므로 걱정할 필요가 없습니다."
+ "이제 어떻게 해야 할까?"
+ "네트워크 문제로 인해 로그인에 실패한 경우 QR 코드로 다시 로그인해 보세요."
+ "동일한 문제를 겪으신 경우 다른 Wi-Fi 네트워크를 사용해 보거나 Wi-Fi 대신 모바일 데이터를 사용해 보세요."
+ "만약 작동하지 않는 경우, 수동으로 로그인하세요."
+ "연결이 안전하지 않습니다"
+ "다른 기기에서 로그인이 취소되었습니다."
+ "로그인 요청이 취소되었습니다"
+ "다른 기기에서 로그인이 거부되었습니다."
+ "로그인 거부됨"
+ "로그인이 만료되었습니다. 다시 시도해 주세요."
+ "로그인 시간이 초과되었습니다."
+ "다른 기기에서는 QR 코드로 %s 에 로그인할 수 없습니다.
+
+수동으로 로그인하거나 다른 기기로 QR 코드를 스캔해 보세요."
+ "QR 코드는 지원되지 않습니다"
+ "귀하의 계정 제공자는 지원하지 않습니다 %1$s ."
+ "%1$s 지원되지 않습니다"
+ "다른 기기에 표시된 QR 코드를 사용하세요."
+ "다시 시도하기"
+ "잘못된 QR 코드"
+ "계속하려면 %1$s 가 기기의 카메라를 사용할 수 있도록 권한을 부여해야 합니다."
+ "카메라 액세스를 허용하여 QR 코드를 스캔하세요"
+ "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-nb/translations.xml b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
new file mode 100644
index 00000000000..af3559d3e8a
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skann QR-koden"
+ "Skann QR-koden med denne enheten"
+ "Klar til å skanne"
+ "Kontotilbyderen din støtter ikke %1$s."
+ "%1$s støttes ikke"
+ "QR-kode støttes ikke"
+ "Påloggingen ble kansellert på den andre enheten."
+ "Påloggingsforespørsel kansellert"
+ "Påloggingen er utløpt. Vennligst prøv igjen."
+ "Påloggingen ble ikke fullført i tide"
+ "Velg %1$s"
+ "En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem."
+ "Hva nå?"
+ "Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem"
+ "Hvis du støter på det samme problemet, kan du prøve et annet wifi-nettverk eller bruke mobildata i stedet for wifi"
+ "Hvis det ikke fungerer, kan du logge på manuelt"
+ "Forbindelsen er ikke sikker"
+ "Påloggingen ble kansellert på den andre enheten."
+ "Påloggingsforespørsel kansellert"
+ "Påloggingen ble avvist på den andre enheten."
+ "Pålogging avslått"
+ "Påloggingen er utløpt. Vennligst prøv igjen."
+ "Påloggingen ble ikke fullført i tide"
+ "Den andre enheten din støtter ikke pålogging på %s med en QR-kode.
+
+Prøv å logge på manuelt, eller skann QR-koden med en annen enhet."
+ "QR-kode støttes ikke"
+ "Kontotilbyderen din støtter ikke %1$s."
+ "%1$s støttes ikke"
+ "Bruk QR-koden som vises på den andre enheten."
+ "Prøv igjen"
+ "Feil QR-kode"
+ "Du må gi tillatelse til at %1$s kan bruke enhetens kamera for å fortsette."
+ "Tillat kameratilgang for å skanne QR-koden"
+ "Det oppstod en uventet feil. Prøv igjen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-nl/translations.xml b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
new file mode 100644
index 00000000000..407a470e489
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Scan de QR-code"
+ "Scan de QR-code met dit apparaat"
+ "Klaar om te scannen"
+ "Je accountprovider ondersteunt geen %1$s."
+ "%1$s wordt niet ondersteund"
+ "QR-code wordt niet ondersteund"
+ "De aanmelding is geannuleerd op het andere apparaat."
+ "Login verzoek geannuleerd"
+ "Aanmelden is verlopen. Probeer het opnieuw."
+ "De aanmelding was niet op tijd voltooid"
+ "Selecteer %1$s"
+ "Er kon geen beveiligde verbinding worden gemaakt met het nieuwe apparaat. Je bestaande apparaten zijn nog steeds veilig en je hoeft je daarover geen zorgen te maken."
+ "Wat nu?"
+ "Probeer opnieuw in te loggen met een QR-code voor het geval dit een netwerkprobleem was"
+ "Als je hetzelfde probleem ondervindt, probeer dan een ander wifi-netwerk of gebruik je mobiele data in plaats van wifi."
+ "Als dat niet werkt, log dan handmatig in"
+ "Verbinding niet veilig"
+ "De aanmelding is geannuleerd op het andere apparaat."
+ "Login verzoek geannuleerd"
+ "De aanmelding is geweigerd op het andere apparaat."
+ "Aanmelden geweigerd"
+ "Aanmelden is verlopen. Probeer het opnieuw."
+ "De aanmelding was niet op tijd voltooid"
+ "Jouw andere apparaat ondersteunt geen inloggen op %s met een QR code.
+
+Probeer handmatig in te loggen, of scan de QR code met een ander apparaat."
+ "QR-code wordt niet ondersteund"
+ "Je accountprovider ondersteunt geen %1$s."
+ "%1$s wordt niet ondersteund"
+ "Gebruik de QR-code die op het andere apparaat wordt weergegeven."
+ "Probeer het opnieuw"
+ "Verkeerde QR-code"
+ "Je moet %1$s toestemming geven om de camera van je apparaat te gebruiken om verder te gaan."
+ "Cameratoegang toestaan om de QR-code te scannen"
+ "Er is een onverwachte fout opgetreden. Probeer het opnieuw."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-pl/translations.xml b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 00000000000..4db42a2a498
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skanuj kod QR"
+ "Zeskanuj kod QR za pomocą tego urządzenia"
+ "Gotowy do skanowania"
+ "Twój dostawca konta nie obsługuje %1$s."
+ "%1$s nie jest wspierany"
+ "Kod QR nie jest wspierany"
+ "Logowanie zostało anulowane na drugim urządzeniu."
+ "Prośba o logowanie została anulowana"
+ "Logowanie wygasło. Spróbuj ponownie."
+ "Logowanie nie zostało ukończone na czas"
+ "Wybierz %1$s"
+ "Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić."
+ "Co teraz?"
+ "Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią"
+ "Jeśli napotkasz ten sam problem, użyj innej sieci Wi-FI lub danych mobilnych"
+ "Jeśli to nie zadziała, zaloguj się ręcznie"
+ "Połączenie nie jest bezpieczne"
+ "Logowanie zostało anulowane na drugim urządzeniu."
+ "Prośba o logowanie została anulowana"
+ "Logowanie zostało odrzucone na drugim urządzeniu."
+ "Logowanie odrzucone"
+ "Logowanie wygasło. Spróbuj ponownie."
+ "Logowanie nie zostało ukończone na czas"
+ "Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR.
+
+Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu."
+ "Kod QR nie jest wspierany"
+ "Twój dostawca konta nie obsługuje %1$s."
+ "%1$s nie jest wspierany"
+ "Użyj kodu QR widocznego na drugim urządzeniu."
+ "Spróbuj ponownie"
+ "Błędny kod QR"
+ "Musisz przyznać uprawnienia %1$s do korzystania z kamery, aby kontynuować."
+ "Zezwól na dostęp do kamery, aby zeskanować kod QR"
+ "Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml
new file mode 100644
index 00000000000..f11bdc6e6d6
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml
@@ -0,0 +1,57 @@
+
+
+ "Leia o código QR"
+ "Abra o %1$s em um computador"
+ "Leia o código QR com este dispositivo"
+ "Pronto para ler"
+ "Abra o %1$s em um computador para receber o código QR"
+ "Os números não conferem"
+ "Digite o código de 2 dígitos"
+ "Isso verificará que a conexão com o seu outro dispositivo é segura."
+ "Digite o número exibido no outro dispositivo"
+ "Seu provedor de conta não tem suporte ao %1$s."
+ "%1$s não suportado"
+ "O seu provedor de conta não tem suporte a autenticação de dispositivos novos com um código QR."
+ "Código QR não suportado"
+ "A entrada foi cancelada no outro dispositivo."
+ "Solicitação de entrada foi cancelada"
+ "O processo de entrada expirou. Tente novamente."
+ "A entrada não foi concluída a tempo"
+ "Abra o %1$s no outro dispositivo"
+ "Selecione %1$s"
+ "\"Entrar com código QR\""
+ "Leia o código QR exibido aqui com o outro dispositivo"
+ "Abra o %1$s no outro dispositivo"
+ "Computador"
+ "Carregando código QR…"
+ "Dispositivo móvel"
+ "Que tipo de dispositivo você deseja vincular?"
+ "Tente novamente e certifique-se que digitou o código de 2 dígitos corretamente. Se os números ainda não conferirem, entre em contato com o provedor da sua conta."
+ "Os números não conferem"
+ "Não foi possível estabelecer uma conexão segura com o novo dispositivo. Seus dispositivos existentes ainda estão seguros e você não precisa se preocupar com eles."
+ "E agora?"
+ "Tente entrar novamente com um código QR caso seja um problema de rede"
+ "Se o problema persistir, tente uma rede Wi-Fi diferente ou use seus dados móveis em vez de Wi-Fi"
+ "Se isso não funcionar, entre manualmente"
+ "Conexão insegura"
+ "A entrada foi cancelada no outro dispositivo."
+ "Solicitação de entrada foi cancelada"
+ "A entrada foi recusada no outro dispositivo."
+ "Entrada recusada"
+ "Você não precisa fazer mais nada."
+ "O seu outro dispositivo já está conectado"
+ "O processo de entrada expirou. Tente novamente."
+ "A entrada não foi concluída a tempo"
+ "Seu outro dispositivo não tem suporte a entrar no %s com um código QR.
+
+Tente entrar manualmente ou ler o código QR com outro dispositivo."
+ "Código QR não suportado"
+ "Seu provedor de conta não tem suporte ao %1$s."
+ "%1$s não suportado"
+ "Use o código QR exibido no outro dispositivo."
+ "Tente novamente"
+ "Código QR errado"
+ "Você deve permitir que o %1$s use a câmera do seu dispositivo para continuar."
+ "Permita o acesso à câmera para ler o código QR"
+ "Ocorreu um erro inesperado. Tente novamente."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-pt/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
new file mode 100644
index 00000000000..da6da08f383
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Ler o código QR"
+ "Lê o código QR com este dispositivo"
+ "Pronto para ler"
+ "O teu operador de conta não suporta %1$s."
+ "%1$s não suportado"
+ "Código QR não suportado"
+ "O início de sessão foi cancelado no outro dispositivo."
+ "Pedido de início de sessão cancelado"
+ "O início de sessão expirou. Por favor, tenta novamente."
+ "O início de sessão não foi concluído a tempo"
+ "Seleciona %1$s"
+ "Não foi possível estabelecer uma ligação segura com o novo dispositivo. Os teus outros dispositivos continuam seguros, não precisas de te preocupar com eles."
+ "E agora?"
+ "Tenta iniciar sessão novamente com um código QR, caso se trate de um problema de rede"
+ "Se tiveres o mesmo problema, experimenta uma rede Wi-Fi diferente ou utiliza os teus dados móveis."
+ "Se isso não funcionar, inicia sessão manualmente"
+ "Ligação insegura"
+ "O início de sessão foi cancelado no outro dispositivo."
+ "Pedido de início de sessão cancelado"
+ "O início de sessão foi rejeitado no outro dispositivo."
+ "Início de sessão rejeitado"
+ "O início de sessão expirou. Por favor, tenta novamente."
+ "O início de sessão não foi concluído a tempo"
+ "O teu outro dispositivo não suporta o início de sessão na %s com um código QR.
+
+Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro dispositivo."
+ "Código QR não suportado"
+ "O teu operador de conta não suporta %1$s."
+ "%1$s não suportado"
+ "Lê o código QR apresentado no outro dispositivo."
+ "Tentar novamente"
+ "Código QR inválido"
+ "Para continuar, tens que dar permissão à %1$s para aceder à câmara do teu dispositivo."
+ "Permitir o acesso à câmara para ler o código QR"
+ "Ocorreu um erro inesperado. Tenta novamente."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 00000000000..f1a4f3db592
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,54 @@
+
+
+ "Scanați codul QR"
+ "Deschide %1$s pe un laptop sau un computer desktop"
+ "Scanați codul QR cu acest dispozitiv"
+ "Gata de scanare"
+ "Deschide %1$s pe un computer desktop pentru a obține codul QR"
+ "Numerele nu se potrivesc"
+ "Introduceți codul de 2 cifre"
+ "Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."
+ "Introduceți numărul afișat pe celălalt dispozitiv"
+ "Furnizorul dumneavoastră de cont nu acceptă %1$s."
+ "%1$s nu este acceptat"
+ "Furnizorul contului dumneavoastră nu acceptă conectarea la un dispozitiv nou cu un cod QR."
+ "Formatul codului QR nu este acceptat."
+ "Autentificarea a fost anulată de pe celălalt dispozitiv."
+ "Cererea de autentificare a fost anulată"
+ "Autentificarea a expirat. Vă rugăm să încercați din nou."
+ "Autentificarea nu a fost finalizată la timp"
+ "Deschideți %1$s pe celălalt dispozitiv"
+ "Selectați %1$s"
+ "“Conectați-vă cu un cod QR”"
+ "Scanați codul QR afișat aici cu celălalt dispozitiv."
+ "Deschideți %1$s pe celălalt dispozitiv"
+ "Calculator desktop"
+ "Se încarcă codul QR…"
+ "Dispozitiv mobil"
+ "Ce tip de dispozitiv doriți să conectați?"
+ "Numerele nu se potrivesc"
+ "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele."
+ "Și acum?"
+ "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea."
+ "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi."
+ "Dacă nu funcționează, conectați-vă manual"
+ "Conexiunea nu este sigură"
+ "Autentificarea a fost anulată de pe celălalt dispozitiv."
+ "Cererea de autentificare a fost anulată"
+ "Autentificarea a fost refuzată pe celălalt dispozitiv."
+ "Autentificarea a fost refuzată"
+ "Autentificarea a expirat. Vă rugăm să încercați din nou."
+ "Autentificarea nu a fost finalizată la timp"
+ "Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR.
+
+Încercați să vă autentificați manual sau să scanați codul QR cu un alt dispozitiv."
+ "Formatul codului QR nu este acceptat."
+ "Furnizorul dumneavoastră de cont nu acceptă %1$s."
+ "%1$s nu este acceptat"
+ "Utilizați codul QR afișat pe celălalt dispozitiv."
+ "Încercați din nou"
+ "Cod QR greșit"
+ "Trebuie să acordați permisiunea ca %1$s să folosească camera dispozitivului pentru a continua."
+ "Permiteți accesul la cameră pentru a scana codul QR"
+ "A apărut o eroare neașteptată. Vă rugăm să încercați din nou."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ru/translations.xml b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 00000000000..16ed8eeb37c
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Сканировать QR-код"
+ "Отсканируйте QR-код с помощью этого устройства"
+ "Готово к сканированию"
+ "Поставщик учетной записи не поддерживает %1$s."
+ "%1$s не поддерживается"
+ "QR-код не поддерживается"
+ "Вход на другом устройстве был отменен."
+ "Запрос на вход отменен"
+ "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Вход в систему не был выполнен вовремя"
+ "Выберите %1$s"
+ "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них."
+ "Что теперь?"
+ "Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением"
+ "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"
+ "Если это не помогло, войдите вручную"
+ "Соединение не защищено"
+ "Вход на другом устройстве был отменен."
+ "Запрос на вход отменен"
+ "Вход в систему был отклонен на другом устройстве."
+ "Вход отклонен"
+ "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Вход в систему не был выполнен вовремя"
+ "Другое устройство не поддерживает вход в %s с помощью QR-кода.
+
+Попробуйте войти вручную или отсканируйте QR-код на другом устройстве."
+ "QR-код не поддерживается"
+ "Поставщик учетной записи не поддерживает %1$s."
+ "%1$s не поддерживается"
+ "Используйте QR-код, показанный на другом устройстве."
+ "Повторить попытку"
+ "Неверный QR-код"
+ "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."
+ "Разрешите доступ к камере для сканирования QR-кода"
+ "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-sk/translations.xml b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 00000000000..9617df3acc0
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Naskenovať QR kód"
+ "Naskenujte QR kód pomocou tohto zariadenia"
+ "Pripravené na skenovanie"
+ "Poskytovateľ vášho účtu nepodporuje %1$s."
+ "%1$s nie je podporovaný"
+ "QR kód nie je podporovaný"
+ "Prihlásenie bolo zrušené na druhom zariadení."
+ "Žiadosť o prihlásenie bola zrušená"
+ "Platnosť prihlásenia vypršala. Skúste to prosím znova."
+ "Prihlásenie nebolo včas dokončené"
+ "Vyberte %1$s"
+ "K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."
+ "Čo teraz?"
+ "Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"
+ "Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta"
+ "Ak to nefunguje, prihláste sa manuálne"
+ "Pripojenie nie je bezpečené"
+ "Prihlásenie bolo zrušené na druhom zariadení."
+ "Žiadosť o prihlásenie bola zrušená"
+ "Prihlásenie bolo zamietnuté na druhom zariadení."
+ "Prihlásenie bolo odmietnuté"
+ "Platnosť prihlásenia vypršala. Skúste to prosím znova."
+ "Prihlásenie nebolo včas dokončené"
+ "Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu.
+
+Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariadenia."
+ "QR kód nie je podporovaný"
+ "Poskytovateľ vášho účtu nepodporuje %1$s."
+ "%1$s nie je podporovaný"
+ "Použite QR kód zobrazený na druhom zariadení."
+ "Skúste to znova"
+ "Nesprávny QR kód"
+ "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."
+ "Povoľte prístup k fotoaparátu na naskenovanie QR kódu"
+ "Vyskytla sa neočakávaná chyba. Prosím, skúste to znova."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-sv/translations.xml b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
new file mode 100644
index 00000000000..8a1bef434ae
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Skanna QR-koden"
+ "Skanna QR-koden med den här enheten"
+ "Redo att skanna"
+ "Din kontoleverantör stöder inte %1$s."
+ "%1$s stöds inte"
+ "QR-kod stöds inte"
+ "Inloggningen avbröts på den andra enheten."
+ "Inloggningsförfrågan avbröts"
+ "Inloggningen har löpt ut. Vänligen försök igen."
+ "Inloggningen slutfördes inte i tid"
+ "Välj %1$s"
+ "En säker anslutning kunde inte göras till den nya enheten. Dina befintliga enheter är fortfarande säkra och du behöver inte oroa dig för dem."
+ "Nu då?"
+ "Pröva att logga in igen med en QR-kod ifall detta skulle vara ett nätverksproblem"
+ "Om du stöter på samma problem, prova ett annat wifi-nätverk eller använd din mobildata istället för wifi"
+ "Om det inte fungerar, logga in manuellt"
+ "Anslutningen är inte säker"
+ "Inloggningen avbröts på den andra enheten."
+ "Inloggningsförfrågan avbröts"
+ "Inloggningen avvisades på den andra enheten."
+ "Inloggning avvisad"
+ "Inloggningen har löpt ut. Vänligen försök igen."
+ "Inloggningen slutfördes inte i tid"
+ "Din andra enhet stöder inte inloggning i %s med en QR-kod.
+
+Prova att logga in manuellt eller skanna QR-koden med en annan enhet."
+ "QR-kod stöds inte"
+ "Din kontoleverantör stöder inte %1$s."
+ "%1$s stöds inte"
+ "Använd QR-koden som visas på den andra enheten."
+ "Försök igen"
+ "Fel QR-kod"
+ "Du måste ge tillstånd för %1$s att använda enhetens kamera för att kunna fortsätta."
+ "Tillåt kameraåtkomst för att skanna QR-koden"
+ "Ett oväntat fel inträffade. Vänligen försök igen."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-tr/translations.xml b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
new file mode 100644
index 00000000000..2d3bebcad8d
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "QR kodunu tara"
+ "QR kodunu bu cihazla tarayın"
+ "Taramaya hazır"
+ "Hesap sağlayıcınız %1$s desteklemiyor."
+ "%1$s desteklenmiyor"
+ "QR kodu desteklenmiyor"
+ "Oturum açma işlemi diğer cihazda iptal edildi."
+ "Oturum açma isteği iptal edildi"
+ "Oturum açma süresi doldu. Lütfen tekrar deneyin."
+ "Oturum açma işlemi zamanında tamamlanmadı"
+ "Seç %1$s"
+ "Yeni cihaza güvenli bir bağlantı kurulamadı. Mevcut cihazlarınız hala güvende ve onlar için endişelenmenize gerek yok."
+ "Şimdi ne olacak?"
+ "Bunun bir ağ sorunu olması ihtimaline karşı bir QR koduyla tekrar oturum açmayı deneyin"
+ "Aynı sorunla karşılaşırsanız, farklı bir wifi ağı deneyin veya wifi yerine mobil verinizi kullanın"
+ "Bu işe yaramazsa, manuel olarak oturum açın"
+ "Bağlantı güvenli değil"
+ "Oturum açma işlemi diğer cihazda iptal edildi."
+ "Oturum açma isteği iptal edildi"
+ "Diğer cihazda oturum açma işlemi reddedildi."
+ "Oturum açma reddedildi"
+ "Oturum açma süresi doldu. Lütfen tekrar deneyin."
+ "Oturum açma işlemi zamanında tamamlanmadı"
+ "Diğer cihazınız %s QR koduyla oturum açmayı desteklemiyor.
+
+Manuel olarak oturum açmayı deneyin veya QR kodunu başka bir cihazla tarayın."
+ "QR kodu desteklenmiyor"
+ "Hesap sağlayıcınız %1$s desteklemiyor."
+ "%1$s desteklenmiyor"
+ "Diğer cihazda gösterilen QR kodunu kullan."
+ "Tekrar deneyin"
+ "Yanlış QR kodu"
+ "Devam etmek için %1$s cihazınızın kamerasını kullanmasına izin vermeniz gerekir."
+ "QR kodunu taramak için kamera erişimine izin verin"
+ "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-uk/translations.xml b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 00000000000..e7104a914da
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "Зіскануйте QR-код"
+ "Зіскануйте QR-код цим пристроєм"
+ "Готовий до сканування"
+ "Постачальник вашого облікового запису не підтримує %1$s."
+ "%1$s не підтримується"
+ "QR-код не підтримується"
+ "Вхід було скасовано на іншому пристрої."
+ "Запит на вхід скасовано"
+ "Термін входу сплив. Будь ласка, спробуйте ще раз."
+ "Вхід не було завершено вчасно"
+ "Виберіть %1$s"
+ "Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."
+ "Що тепер?"
+ "Спробуйте увійти ще раз за допомогою QR-коду, якщо це була проблема з мережею"
+ "Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi"
+ "Якщо це не спрацює, увійдіть вручну"
+ "З\'єднання не безпечне"
+ "Вхід було скасовано на іншому пристрої."
+ "Запит на вхід скасовано"
+ "Вхід був відхилений на іншому пристрої."
+ "Вхід відхилено"
+ "Термін входу сплив. Будь ласка, спробуйте ще раз."
+ "Вхід не було завершено вчасно"
+ "Ваш інший пристрій не підтримує вхід у %s за допомогою QR-коду.
+
+Спробуйте ввійти вручну або відскануйте QR-код за допомогою іншого пристрою."
+ "QR-код не підтримується"
+ "Постачальник вашого облікового запису не підтримує %1$s."
+ "%1$s не підтримується"
+ "Використовуйте QR-код, показаний на іншому пристрої."
+ "Спробуйте ще раз"
+ "Неправильний QR-код"
+ "Вам потрібно дати дозвіл %1$s на використання камери вашого пристрою, щоб продовжити."
+ "Надайте доступ до камери, щоб сканувати QR-код"
+ "Сталася несподівана помилка. Будь ласка, спробуйте ще раз."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-ur/translations.xml b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml
new file mode 100644
index 00000000000..54d2c2e401b
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "کیو آر رمز مسح ضوئی کریں"
+ "اس آلے کے ساتھ کیو آر رمز مسح ضوئی کریں"
+ "مسح ضوئی کیلئے تیار"
+ "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔"
+ "%1$s تعاون یافتہ نہیں"
+ "کر رمز غیر تعاون یافتہ"
+ "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔"
+ "دخول کی درخواست منسوخ"
+ "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔"
+ "دخول وقت پر مکمل نہیں ہوا تھا"
+ "%1$s منتخب کریں"
+ "نئے آلے سے محفوظ اتصال نہیں بنایا جا سکا۔ آپ کے موجودہ آلات اب بھی محفوظ ہیں اور آپ کو ان کے بارے میں فکر کرنے کی ضرورت نہیں ہے۔"
+ "اب کیا؟"
+ "اگر یہ شبکہ کا مسئلہ تھا تو کیو آر رمز کے ساتھ دوبارہ داخل ہونے کی کوشش کریں۔"
+ "اگر آپ کو بھی یہی مسئلہ درپیش ہو، تو کوئی دوسرا وائی فائی شبکہ آزمائیں یا وائی فائی کے بجائے اپنے محمول بیانات استعمال کریں۔"
+ "اگر یہ کام نہ کرے، تو دستی طور پر داخل ہوں"
+ "اتصال محفوظ نہیں"
+ "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔"
+ "دخول کی درخواست منسوخ"
+ "دوسرے آلہ پر دخول کو مسترد کر دیا گیا تھا۔"
+ "دخول مسترد کیا گیا"
+ "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔"
+ "دخول وقت پر مکمل نہیں ہوا تھا"
+ "آپ کا دوسرا آلہ کیو آر رمز کے ساتھ %s میں دخول کا تعاون نہیں کرتا۔
+
+دستی طور پر داخل ہونے کی کوشش کریں ، یا کسی دوسرے آلے سے کیو آر رمز مسح ضوئی کریں۔"
+ "کر رمز غیر تعاون یافتہ"
+ "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔"
+ "%1$s تعاون یافتہ نہیں"
+ "دوسرے آلے پر دکھایا گیا کیو آر رمز استعمال کریں۔"
+ "دوبارہ کوشش کریں"
+ "غلط کیو آر رمز"
+ "جاری رکھنے کے لیے آپ %1$s کو اپنے آلے کا تصویرگر استعمال کرنے کی اجازت دینے کی ضرورت ہے۔"
+ "کیو آر رمز کو مسح ضوئی کرنے کے لئے تصویرگر تک رسائی کی اجازت دیں"
+ "ایک غیر متوقع نقص واقع ہوا۔ برائے مہربانی دوبارہ کوشش کریں۔"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-uz/translations.xml b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 00000000000..d2f3b7a8650
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "QR kodni skanerlash"
+ "Bu qurilma bilan QR kodni skanerlang"
+ "Skanerlashga tayyor"
+ "Hisob provayderingiz %1$s bilan ishlamaydi."
+ "%1$s qoʻllab-quvvatlanmaydi"
+ "QR kod qoʻllab-quvvatlanmaydi"
+ "Boshqa qurilmadan hisobga kirish bekor qilindi."
+ "Tizimga kirish soʻrovi bekor qilindi"
+ "Kirish muddati tugagan. Iltimos, qayta urinib koʻring."
+ "Kirish oʻz vaqtida tugallanmagan"
+ "%1$sʼni tanlang"
+ "Yangi qurilmaga xavfsiz ulanish amalga oshirilmadi. Mavjud qurilmalaringiz hali ham xavfsiz va ular haqida qaygʻurishingiz shart emas."
+ "Endi nima?"
+ "Agar bu tarmoq muammosi boʻlsa, QR kod bilan qayta kiring"
+ "Xuddi shu muammoga duch kelsangiz, boshqa wifi tarmogʻini sinang yoki wifi oʻrniga mobil internetdan foydalaning"
+ "Agar bunisi ishlamasa, oddiy usulda kiring"
+ "Ulanish xavfsiz emas"
+ "Boshqa qurilmadan hisobga kirish bekor qilindi."
+ "Tizimga kirish soʻrovi bekor qilindi"
+ "Boshqa qurilmadan hisobga kirish bekor qilindi."
+ "Tizimga kirish rad etildi"
+ "Kirish muddati tugagan. Iltimos, qayta urinib koʻring."
+ "Kirish oʻz vaqtida tugallanmagan"
+ "Boshqa qurilmangiz %s hisobiga QR kod orqali kirishni qoʻllab-quvvatlamaydi.
+
+Oddiy usulda kiring yoki boshqa qurilma bilan QR kodni skanerlang."
+ "QR kod qoʻllab-quvvatlanmaydi"
+ "Hisob provayderingiz %1$s bilan ishlamaydi."
+ "%1$s qoʻllab-quvvatlanmaydi"
+ "Narigi qurilmada koʻrsatilgan QR koddan foydalaning."
+ "Qayta urinib ko\'ring"
+ "QR kod notoʻgʻri"
+ "Davom etish uchun %1$s qurilmangiz kamerasidan foydalanishiga ruxsat berishingiz kerak."
+ "QR kodni skanerlash uchun kameraga ruxsat bering"
+ "Kutilmagan xatolik yuz berdi. Qayta urining."
+
diff --git a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
new file mode 100644
index 00000000000..9b3cd5ead5e
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "掃描 QR code"
+ "使用此裝置掃描 QR code"
+ "準備掃描"
+ "您的帳號提供者不支援 %1$s。"
+ "不支援 %1$s"
+ "不支援 QR code"
+ "已在其他裝置上取消登入。"
+ "已取消登入請求"
+ "登入已過期。請再試一次。"
+ "未及時完成登入"
+ "選取 %1$s"
+ "無法與新裝置建立安全連線。您現有的裝置仍然安全,您不必擔心它們。"
+ "現在怎麼辦?"
+ "嘗試再次使用 QR code 登入以確認不是網路問題"
+ "如果遇到相同的問題,請嘗試使用其他 wifi 網路或您的行動數據"
+ "若無法運作,請手動登入"
+ "連線不安全"
+ "已在其他裝置上取消登入。"
+ "已取消登入請求"
+ "其他裝置拒絕登入。"
+ "已拒絕登入"
+ "登入已過期。請再試一次。"
+ "未及時完成登入"
+ "您的其他裝置不支援使用 QR cpde 登入 %s。
+
+嘗試手動登入,或是使用其他裝置掃描 QR code。"
+ "不支援 QR code"
+ "您的帳號提供者不支援 %1$s。"
+ "不支援 %1$s"
+ "使用其他裝置上顯示的 QR code。"
+ "再試一次"
+ "錯誤的 QR code"
+ "您必須授予 %1$s 權限以使用裝置相機才能繼續。"
+ "允許相機權限以掃描 QR code"
+ "發生意外錯誤。請再試一次。"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-zh/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
new file mode 100644
index 00000000000..99359cc6955
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "扫描二维码"
+ "使用此设备扫描二维码"
+ "准备进行扫描"
+ "账户提供方不支持 %1$s."
+ "不支持 %1$s."
+ "不支持二维码"
+ "登录被另一台设备取消"
+ "登录请求已取消"
+ "登录已过期. 请重试."
+ "登录未及时完成"
+ "选择 %1$s"
+ "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。"
+ "现在怎么办?"
+ "如果这是网络问题,请尝试使用二维码再次登录"
+ "如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi"
+ "如果不起作用,请手动登录"
+ "连接不安全"
+ "登录被另一台设备取消"
+ "登录请求已取消"
+ "其它设备未接受请求"
+ "登录被拒绝"
+ "登录已过期. 请重试."
+ "登录未及时完成"
+ "另一个设备不支持使用二维码登录 %s.
+
+尝试手动或使用另一个设备扫描二维码."
+ "不支持二维码"
+ "账户提供方不支持 %1$s."
+ "不支持 %1$s."
+ "使用其他设备上显示的二维码。"
+ "再试一次"
+ "二维码错误"
+ "您需要授予 %1$s 使用设备摄像头的权限才能继续。"
+ "允许摄像头权限以扫描 QR 码"
+ "发生了意外错误。请再试一次。"
+
diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml
new file mode 100644
index 00000000000..321b1687518
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,57 @@
+
+
+ "Scan the QR code"
+ "Open %1$s on a laptop or desktop computer"
+ "Scan the QR code with this device"
+ "Ready to scan"
+ "Open %1$s on a desktop computer to get the QR code"
+ "The numbers don’t match"
+ "Enter 2-digit code"
+ "This will verify that the connection to your other device is secure."
+ "Enter the number shown on your other device"
+ "Your account provider does not support %1$s."
+ "%1$s not supported"
+ "Your account provider doesn’t support signing into a new device with a QR code."
+ "QR code not supported"
+ "The sign in was cancelled on the other device."
+ "Sign in request cancelled"
+ "Sign in expired. Please try again."
+ "The sign in was not completed in time"
+ "Open %1$s on the other device"
+ "Select %1$s"
+ "“Sign in with QR code”"
+ "Scan the QR code shown here with the other device"
+ "Open %1$s on the other device"
+ "Desktop computer"
+ "Loading QR code…"
+ "Mobile device"
+ "What type of device do you want to link?"
+ "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider."
+ "The numbers don’t match"
+ "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."
+ "What now?"
+ "Try signing in again with a QR code in case this was a network problem"
+ "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"
+ "If that doesn’t work, sign in manually"
+ "Connection not secure"
+ "The sign in was cancelled on the other device."
+ "Sign in request cancelled"
+ "The sign in was declined on the other device."
+ "Sign in declined"
+ "You don’t need to do anything else."
+ "Your other device is already signed in"
+ "Sign in expired. Please try again."
+ "The sign in was not completed in time"
+ "Your other device does not support signing in to %s with a QR code.
+
+Try signing in manually, or scan the QR code with another device."
+ "QR code not supported"
+ "Your account provider does not support %1$s."
+ "%1$s not supported"
+ "Use the QR code shown on the other device."
+ "Try again"
+ "Wrong QR code"
+ "You need to give permission for %1$s to use your device’s camera in order to continue."
+ "Allow camera access to scan the QR code"
+ "An unexpected error occurred. Please try again."
+
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt
new file mode 100644
index 00000000000..2957a89495a
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.node.TestParentNode
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class DefaultLinkNewDeviceEntryPointTest {
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @Test
+ fun `test node creation`() = runTest {
+ val entryPoint = DefaultLinkNewDeviceEntryPoint()
+ val client = FakeMatrixClient()
+ val parentNode = TestParentNode.create { buildContext, plugins ->
+ LinkNewDeviceFlowNode(
+ buildContext = buildContext,
+ plugins = plugins,
+ sessionCoroutineScope = backgroundScope,
+ linkNewMobileHandler = LinkNewMobileHandler(client),
+ linkNewDesktopHandler = LinkNewDesktopHandler(client),
+ )
+ }
+ val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
+ override fun onDone() = lambdaError()
+ }
+ val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback)
+ assertThat(result).isInstanceOf(LinkNewDeviceFlowNode::class.java)
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt
new file mode 100644
index 00000000000..b6b9769b644
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DesktopNoticePresenterTest {
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createPresenter()
+ presenter.test {
+ awaitItem().run {
+ assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS")
+ assertThat(canContinue).isFalse()
+ }
+ }
+ }
+
+ @Test
+ fun `present - Continue with camera permissions can continue`() = runTest {
+ val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
+ val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
+ presenter.test {
+ awaitItem().eventSink(DesktopNoticeEvent.Continue)
+ assertThat(awaitItem().canContinue).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest {
+ val permissionsPresenter = FakePermissionsPresenter()
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
+ val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory)
+ presenter.test {
+ awaitItem().eventSink(DesktopNoticeEvent.Continue)
+ assertThat(awaitItem().cameraPermissionState.showDialog).isTrue()
+ }
+ }
+}
+
+private fun createPresenter(
+ permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(),
+) = DesktopNoticePresenter(
+ permissionsPresenterFactory = permissionsPresenterFactory,
+)
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt
new file mode 100644
index 00000000000..ac0a129f491
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.desktop
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DesktopNoticeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `when can continue - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aDesktopNoticeState(canContinue = true),
+ onReadyToScanClick = callback,
+ )
+ }
+ }
+
+ @Test
+ fun `on submit button clicked - emits the Continue event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aDesktopNoticeState(eventSink = eventRecorder),
+ )
+ rule.clickOn(R.string.screen_link_new_device_desktop_submit)
+ eventRecorder.assertSingle(DesktopNoticeEvent.Continue)
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: DesktopNoticeState,
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ onReadyToScanClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ DesktopNoticeView(
+ state = state,
+ onBackClick = onBackClicked,
+ onReadyToScanClick = onReadyToScanClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
new file mode 100644
index 00000000000..8f44182dd4e
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.error
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ErrorViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the onRetry callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onRetry = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on start over button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setErrorView(
+ onRetry = callback
+ )
+ rule.clickOn(CommonStrings.action_start_over)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setErrorView(
+ onRetry: () -> Unit,
+ errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
+ ) {
+ setContent {
+ ErrorView(
+ errorScreenType = errorScreenType,
+ onRetry = onRetry,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
new file mode 100644
index 00000000000..fe2e11e95cf
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.linknewdevice.FakeCheckCodeSender
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class EnterNumberPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.number).isEmpty()
+ assertThat(initialState.sendingCode.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - enter numbers`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.number).isEmpty()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("12"))
+ val state2 = awaitItem()
+ assertThat(state2.number).isEqualTo("12")
+ // Non numeric characters are ignored
+ state2.eventSink(EnterNumberEvent.UpdateNumber("1a"))
+ val state3 = awaitItem()
+ assertThat(state3.number).isEqualTo("1")
+ }
+ }
+
+ @Test
+ fun `present - continue in wrong state generates an error`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ initialState.eventSink(EnterNumberEvent.Continue)
+ val state2 = awaitItem()
+ assertThat(state2.sendingCode.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - continue when number is not valid invokes the navigator`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { false }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ val navigateToWrongNumberErrorLambda = lambdaRecorder { }
+ val navigator = FakeEnterNumberNavigator(
+ navigateToWrongNumberErrorLambda = navigateToWrongNumberErrorLambda,
+ )
+ createPresenter(
+ navigator = navigator,
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val finalState = awaitItem()
+ assertThat(finalState.sendingCode.isLoading()).isTrue()
+ advanceUntilIdle()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ navigateToWrongNumberErrorLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - continue when the number is valid but sending fails`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { true }
+ val sendResult = lambdaRecorder> { Result.failure(AN_EXCEPTION) }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ sendResult = sendResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.sendingCode.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.sendingCode.isFailure()).isTrue()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ sendResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ }
+ }
+
+ @Test
+ fun `present - continue when the number is valid and sending is successful`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val validateResult = lambdaRecorder { true }
+ val sendResult = lambdaRecorder> { Result.success(Unit) }
+ val checkCodeSender = FakeCheckCodeSender(
+ validateResult = validateResult,
+ sendResult = sendResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ val initialState = awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrScanned(checkCodeSender)
+ )
+ runCurrent()
+ initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
+ skipItems(1)
+ initialState.eventSink(EnterNumberEvent.Continue)
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.sendingCode.isLoading()).isTrue()
+ expectNoEvents()
+ advanceUntilIdle()
+ validateResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ sendResult.assertions().isCalledOnce().with(value(88.toUByte()))
+ }
+ }
+
+ private fun createPresenter(
+ navigator: EnterNumberNavigator = FakeEnterNumberNavigator(),
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
+ ) = EnterNumberPresenter(
+ navigator = navigator,
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt
new file mode 100644
index 00000000000..e7466a1b215
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.screens.number.model.Digit
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import org.junit.Test
+
+class EnterNumberStateTest {
+ @Test
+ fun `isContinueButtonEnabled is false if number is not complete`() {
+ val sut = aEnterNumberState(
+ number = "",
+ sendingCode = AsyncAction.Uninitialized,
+ )
+ assertThat(sut.copy(number = "1").isContinueButtonEnabled).isFalse()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is true if number is complete`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = AsyncAction.Uninitialized,
+ )
+ assertThat(sut.isContinueButtonEnabled).isTrue()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is false if number is complete and sending is loading`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = AsyncAction.Loading,
+ )
+ assertThat(sut.isContinueButtonEnabled).isFalse()
+ }
+
+ @Test
+ fun `isContinueButtonEnabled is true if number is complete and sending is not loading`() {
+ listOf(
+ AsyncAction.Uninitialized,
+ AsyncAction.Failure(AN_EXCEPTION),
+ AsyncAction.Success(Unit),
+ ).forEach { action ->
+ val sut = aEnterNumberState(
+ number = "12",
+ sendingCode = action,
+ )
+ assertThat(sut.isContinueButtonEnabled).isTrue()
+ }
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case empty`() {
+ val sut = aEnterNumberState(
+ number = "",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Empty,
+ Digit.Empty,
+ )
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case half filled`() {
+ val sut = aEnterNumberState(
+ number = "1",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Filled('1'),
+ Digit.Empty,
+ )
+ }
+
+ @Test
+ fun `numberEntry is computed from number - case filled`() {
+ val sut = aEnterNumberState(
+ number = "12",
+ )
+ assertThat(sut.numberEntry.size).isEqualTo(2)
+ assertThat(sut.numberEntry.digits).containsExactly(
+ Digit.Filled('1'),
+ Digit.Filled('2'),
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt
new file mode 100644
index 00000000000..20e1d898ddc
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class EnterNumberViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aEnterNumberState(),
+ onBackClicked = callback,
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `on back button clicked - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aEnterNumberState(),
+ onBackClicked = callback,
+ )
+ rule.pressBack()
+ }
+ }
+
+ @Test
+ fun `on continue button clicked - emits the Continue event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aEnterNumberState(
+ number = "12",
+ eventSink = eventRecorder,
+ ),
+ )
+ rule.clickOn(CommonStrings.action_continue)
+ eventRecorder.assertSingle(EnterNumberEvent.Continue)
+ }
+
+ @Test
+ fun `when the number is not complete, continue button is disabled`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ rule.setView(
+ state = aEnterNumberState(
+ number = "1",
+ eventSink = eventRecorder,
+ ),
+ )
+ val continueStr = rule.activity.getString(CommonStrings.action_continue)
+ rule.onNodeWithText(continueStr).assertIsNotEnabled()
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: EnterNumberState,
+ onBackClicked: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ EnterNumberView(
+ state = state,
+ onBackClick = onBackClicked,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt
new file mode 100644
index 00000000000..a96ab7fe303
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.number
+
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeEnterNumberNavigator(
+ private val navigateToWrongNumberErrorLambda: () -> Unit = { lambdaError() },
+) : EnterNumberNavigator {
+ override fun navigateToWrongNumberError() {
+ navigateToWrongNumberErrorLambda()
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt
new file mode 100644
index 00000000000..c6c89ba8189
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.qrcode
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ShowQrCodeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ ensureCalledOnce { callback ->
+ rule.setView(
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ShowQrCodeView(
+ data = "DATA",
+ onBackClick = onBackClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
new file mode 100644
index 00000000000..88a68786f3c
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LinkNewDeviceRootPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.dataOrNull()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - new login device not supported`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(false) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.dataOrNull()).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - error`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.failure(AN_EXCEPTION) },
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.isUninitialized()).isTrue()
+ assertThat(awaitItem().isSupported.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - link new mobile device`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isSupported.dataOrNull()).isTrue()
+ initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
+ val loadingState = awaitItem()
+ assertThat(loadingState.qrCodeData.isLoading()).isTrue()
+ }
+ }
+
+ private fun createPresenter(
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(matrixClient),
+ ) = LinkNewDeviceRootPresenter(
+ matrixClient = matrixClient,
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt
new file mode 100644
index 00000000000..e352debfb0f
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.root
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.linknewdevice.impl.R
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LinkNewDeviceRootViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the onRetry callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `link desktop button clicked - calls the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ ),
+ onLinkDesktopDeviceClick = callback,
+ )
+ rule.clickOn(R.string.screen_link_new_device_root_desktop_computer)
+ }
+ }
+
+ @Test
+ fun `link mobile button clicked - emits the expected event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ )
+ )
+ rule.clickOn(R.string.screen_link_new_device_root_mobile_device)
+ eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
+ }
+
+ @Test
+ fun `not supported - dismiss click - invokes the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(false),
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback,
+ )
+ rule.clickOn(CommonStrings.action_dismiss)
+ }
+ }
+
+ private fun AndroidComposeTestRule.setLinkNewDeviceRootView(
+ state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ LinkNewDeviceRootView(
+ state = state,
+ onBackClick = onBackClick,
+ onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt
new file mode 100644
index 00000000000..50c3ce767bf
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
+import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE
+import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkDesktopHandler
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class ScanQrCodePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val matrixClient = FakeMatrixClient(
+ createLinkDesktopHandlerResult = { Result.success(FakeLinkDesktopHandler()) }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - handle scanned event - success`() = runTest {
+ val handleScannedQrCodeResult = lambdaRecorder { }
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkDesktopHandlerResult = {
+ Result.success(
+ FakeLinkDesktopHandler(
+ handleScannedQrCodeResult = handleScannedQrCodeResult,
+ )
+ )
+ }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE))
+ val scannedState = awaitItem()
+ assertThat(scannedState.scanAction.isSuccess()).isTrue()
+ runCurrent()
+ handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE))
+ }
+ }
+
+ @Test
+ fun `present - handle scanned event - failure`() = runTest {
+ val handleScannedQrCodeResult = lambdaRecorder { }
+ val handler = FakeLinkDesktopHandler(
+ handleScannedQrCodeResult = handleScannedQrCodeResult,
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkDesktopHandlerResult = {
+ Result.success(handler)
+ }
+ )
+ createPresenter(
+ matrixClient = matrixClient,
+ ).test {
+ val initialState = awaitItem()
+ assertThat(initialState.scanAction.isLoading()).isTrue()
+ initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE))
+ val scannedState = awaitItem()
+ assertThat(scannedState.scanAction.isSuccess()).isTrue()
+ handler.emitStep(LinkDesktopStep.InvalidQrCode(QrCodeDecodeException.Crypto("Invalid QR Code")))
+ skipItems(1)
+ val errorState = awaitItem()
+ assertThat(errorState.scanAction.isFailure()).isTrue()
+ handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE))
+ // Reset by trying again
+ errorState.eventSink(ScanQrCodeEvent.TryAgain)
+ val resetState = awaitItem()
+ assertThat(resetState.scanAction.isLoading()).isTrue()
+ }
+ }
+}
+
+private fun createPresenter(
+ matrixClient: MatrixClient,
+) = ScanQrCodePresenter(
+ linkNewDesktopHandler = LinkNewDesktopHandler(matrixClient),
+)
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt
new file mode 100644
index 00000000000..fcc3afeb7d2
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.linknewdevice.impl.screens.scan
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.clickOn
+import io.element.android.tests.testutils.ensureCalledOnce
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestRule
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ScanQrCodeViewTest {
+ @get:Rule
+ val rule = createAndroidComposeRule()
+
+ @Test
+ fun `on back pressed - calls the expected callback`() {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnce { callback ->
+ rule.setView(
+ state = aScanQrCodeState(
+ eventSink = eventRecorder,
+ ),
+ onBackClick = callback
+ )
+ rule.pressBackKey()
+ }
+ }
+
+ @Test
+ fun `try again button clicked - emits the expected event`() {
+ val eventRecorder = EventsRecorder()
+ rule.setView(
+ state = aScanQrCodeState(
+ scanAction = AsyncAction.Failure(AN_EXCEPTION),
+ eventSink = eventRecorder,
+ )
+ )
+ rule.clickOn(CommonStrings.action_try_again)
+ eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain)
+ }
+
+ private fun AndroidComposeTestRule.setView(
+ state: ScanQrCodeState = aScanQrCodeState(),
+ onBackClick: () -> Unit = EnsureNeverCalled(),
+ ) {
+ setContent {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = onBackClick,
+ )
+ }
+ }
+}
diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts
new file mode 100644
index 00000000000..388612f9202
--- /dev/null
+++ b/features/linknewdevice/test/build.gradle.kts
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+plugins {
+ id("io.element.android-compose-library")
+}
+
+android {
+ namespace = "io.element.android.features.linknewdevice.test"
+}
+
+dependencies {
+ implementation(projects.features.linknewdevice.api)
+ implementation(projects.tests.testutils)
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
index 1ebf00dd106..091432044a1 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
@@ -71,7 +71,7 @@ class DefaultPinCodeManager(
lockScreenStore.onWrongPin()
}
}
- } catch (failure: Throwable) {
+ } catch (_: Throwable) {
false
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
index 2131853e3ee..093ad2f2b45 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
@@ -21,8 +21,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -40,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times
import io.element.android.compound.theme.ElementTheme
+import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
@@ -206,7 +205,8 @@ private fun PinKeypadBackButton(
onClick = onClick,
) {
Icon(
- imageVector = Icons.AutoMirrored.Filled.Backspace,
+ modifier = Modifier.size(28.dp),
+ imageVector = CompoundIcons.BackspaceSolid(),
contentDescription = stringResource(CommonStrings.a11y_delete),
)
}
diff --git a/features/lockscreen/impl/src/main/res/values-hr/translations.xml b/features/lockscreen/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..1a81bcc6cb4
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,40 @@
+
+
+ "biometrijska provjera autentičnosti"
+ "biometrijsko otključavanje"
+ "Otključavanje biometrijom"
+ "Potvrdi biometriju"
+ "Zaboravili ste PIN?"
+ "Promijeni PIN kod"
+ "Omogući biometrijsko otključavanje"
+ "Ukloni PIN"
+ "Jeste li sigurni da želite ukloniti PIN?"
+ "Želite li ukloniti PIN?"
+ "Dopusti %1$s"
+ "Radije bih upotrijebio/la PIN"
+ "Uštedite si malo vremena i iskoristite %1$s kako biste svaki put otključali aplikaciju"
+ "Odaberite PIN"
+ "Potvrdite PIN"
+ "Zaključajte %1$s kako biste dodatno osigurali svoje razgovore.
+
+Odaberite nešto nezaboravno. Ako zaboravite ovaj PIN, bit ćete odjavljeni iz aplikacije."
+ "Ovo ne možete iz sigurnosnih razloga odabrati kao svoj PIN kod"
+ "Odaberite drugi PIN"
+ "Unesite dvaput isti PIN"
+ "PIN-ovi se ne podudaraju"
+ "Morat ćete se ponovno prijaviti i izraditi novi PIN da biste mogli nastaviti"
+ "Odjavit ćete se"
+
+ - "Imate %1$d pokušaj otključavanja"
+ - "Imate %1$d pokušaja otključavanja"
+ - "Imate %1$d pokušaja otključavanja"
+
+
+ - "Pogrešan PIN. Imate još %1$d pokušaj"
+ - "Pogrešan PIN. Imate još %1$d pokušaja"
+ - "Pogrešan PIN. Imate još %1$d pokušaja"
+
+ "Upotrijebi biometriju"
+ "Upotrijebi PIN"
+ "Odjavljivanje…"
+
diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml
index 453cf051320..f2d84131a73 100644
--- a/features/login/impl/src/main/AndroidManifest.xml
+++ b/features/login/impl/src/main/AndroidManifest.xml
@@ -15,4 +15,6 @@
+
+
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
index 4523e6f45e8..12b9106b711 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt
@@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.changeserver.ChangeServerState
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.Presenter
@ContributesTo(AppScope::class)
@@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter
interface LoginModule {
@Binds
fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter
+
+ @Binds
+ fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
index ffa1f044d3d..24c7042bc6d 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt
@@ -21,25 +21,19 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeout
-import kotlin.time.Duration.Companion.seconds
@AssistedInject
class CreateAccountPresenter(
@Assisted private val url: String,
private val authenticationService: MatrixAuthenticationService,
- private val clientProvider: MatrixClientProvider,
private val messageParser: MessageParser,
private val featureFlagService: FeatureFlagService,
) : Presenter {
@@ -87,12 +81,6 @@ class CreateAccountPresenter(
}.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession)
}.onSuccess { sessionId ->
- tryOrNull {
- // Wait until the session is verified
- val client = clientProvider.getOrRestore(sessionId).getOrThrow()
- val sessionVerificationService = client.sessionVerificationService
- withTimeout(10.seconds) { sessionVerificationService.sessionVerifiedStatus.first { it.isVerified() } }
- }
loggedInState.value = AsyncAction.Success(sessionId)
}.onFailure { failure ->
loggedInState.value = AsyncAction.Failure(failure)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
index 6c8c7915264..c3ec3c0b939 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt
@@ -26,6 +26,7 @@ import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@@ -44,6 +45,7 @@ class OnBoardingPresenter(
private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider,
private val sessionStore: SessionStore,
private val accountProviderDataSource: AccountProviderDataSource,
+ private val loginWithClassicPresenter: Presenter,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -100,6 +102,8 @@ class OnBoardingPresenter(
val loginMode by loginHelper.collectLoginMode()
+ val loginWithClassicState = loginWithClassicPresenter.present()
+
fun handleEvent(event: OnBoardingEvents) {
when (event) {
is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch {
@@ -133,6 +137,7 @@ class OnBoardingPresenter(
loginMode = loginMode,
version = buildMeta.versionName,
onBoardingLogoResId = onBoardingLogoResId,
+ loginWithClassicState = loginWithClassicState,
eventSink = ::handleEvent,
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt
index db6c3573f92..703120b260e 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt
@@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginMode
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
data class OnBoardingState(
@@ -24,6 +25,7 @@ data class OnBoardingState(
@DrawableRes
val onBoardingLogoResId: Int?,
val loginMode: AsyncData,
+ val loginWithClassicState: LoginWithClassicState,
val eventSink: (OnBoardingEvents) -> Unit,
) {
val submitEnabled: Boolean
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
index 7764c25c0bc..445b17f82e9 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt
@@ -11,6 +11,8 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
+import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.R
@@ -44,6 +46,7 @@ fun anOnBoardingState(
@DrawableRes
customLogoResId: Int? = null,
loginMode: AsyncData = AsyncData.Uninitialized,
+ loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(),
eventSink: (OnBoardingEvents) -> Unit = {},
) = OnBoardingState(
isAddingAccount = isAddingAccount,
@@ -56,5 +59,6 @@ fun anOnBoardingState(
version = version,
loginMode = loginMode,
onBoardingLogoResId = customLogoResId,
+ loginWithClassicState = loginWithClassicState,
eventSink = eventSink,
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt
index 2bd628460e3..6174a1a029b 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt
@@ -31,15 +31,22 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeView
+import io.element.android.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent
+import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.components.BigIcon
+import io.element.android.libraries.designsystem.components.async.AsyncActionView
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@@ -109,6 +116,43 @@ fun OnBoardingView(
buttons = buttons,
)
}
+
+ LoginWithElementClassicView(
+ state = state.loginWithClassicState,
+ )
+}
+
+@Composable
+private fun LoginWithElementClassicView(
+ state: LoginWithClassicState,
+) {
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
+ state.eventSink(LoginWithClassicEvent.RefreshData)
+ }
+ AsyncActionView(
+ async = state.loginWithClassicAction,
+ confirmationDialog = { confirming ->
+ when (confirming) {
+ is ConfirmingLoginWithElementClassic -> {
+ // TODO i18n
+ ConfirmationDialog(
+ title = "Sign in with Element Classic",
+ content = "You are signing in as ${confirming.userId} on Element Classic." +
+ " Your existing session on Element Classic will not be signed out. Do you want to continue?",
+ submitText = stringResource(CommonStrings.action_continue),
+ onSubmitClick = { state.eventSink(LoginWithClassicEvent.DoLoginWithClassic) },
+ onDismiss = { state.eventSink(LoginWithClassicEvent.CloseDialog) },
+ )
+ }
+ }
+ },
+ onErrorDismiss = {
+ state.eventSink(LoginWithClassicEvent.CloseDialog)
+ },
+ onSuccess = {
+ // noop, the view will be closed
+ }
+ )
}
@Composable
@@ -239,6 +283,18 @@ private fun OnBoardingButtons(
} else {
CommonStrings.action_continue
}
+ if (state.loginWithClassicState.canLoginWithClassic) {
+ Button(
+ text = "Sign in with Element Classic",
+ leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
+ onClick = {
+ state.loginWithClassicState.eventSink(
+ LoginWithClassicEvent.StartLoginWithClassic
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
if (state.canLoginWithQrCode) {
Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code),
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt
new file mode 100644
index 00000000000..5fae0afdd59
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt
@@ -0,0 +1,15 @@
+/*
+ * 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.screens.onboarding.classic
+
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.UserId
+
+class ConfirmingLoginWithElementClassic(
+ val userId: UserId,
+) : AsyncAction.Confirming
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt
new file mode 100644
index 00000000000..c983ea04bab
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt
@@ -0,0 +1,249 @@
+/*
+ * 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.screens.onboarding.classic
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Context.BIND_AUTO_CREATE
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Message
+import android.os.Messenger
+import android.os.RemoteException
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.core.log.logger.LoggerTag
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.core.meta.BuildType
+import io.element.android.libraries.di.annotations.AppCoroutineScope
+import io.element.android.libraries.di.annotations.ApplicationContext
+import io.element.android.libraries.matrix.api.core.UserId
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+interface ElementClassicConnection {
+ fun start()
+ fun stop()
+ fun requestData()
+ val stateFlow: StateFlow
+}
+
+sealed interface ElementClassicConnectionState {
+ object Idle : ElementClassicConnectionState
+ object ElementClassicNotFound : ElementClassicConnectionState
+ object ElementClassicReadyNoSession : ElementClassicConnectionState
+ data class ElementClassicReady(val userId: UserId) : ElementClassicConnectionState
+ data class Error(val error: String) : ElementClassicConnectionState
+}
+
+private val loggerTag = LoggerTag("ECConnection")
+
+@ContributesBinding(AppScope::class)
+class DefaultElementClassicConnection(
+ @ApplicationContext
+ private val context: Context,
+ @AppCoroutineScope
+ private val coroutineScope: CoroutineScope,
+ private val buildMeta: BuildMeta,
+) : ElementClassicConnection {
+ // Messenger for communicating with the service.
+ private var messenger: Messenger? = null
+
+ // Target we publish for external service to send messages to IncomingHandler.
+ private val incomingMessenger: Messenger = Messenger(IncomingHandler())
+
+ // Flag indicating whether we have called bind on the service.
+ private var bound: Boolean = false
+
+ /**
+ * Class for interacting with the main interface of the service.
+ */
+ private val serviceConnection = object : ServiceConnection {
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ Timber.tag(loggerTag.value).d("onServiceConnected")
+ // This is called when the connection with the service has been
+ // established, giving us the object we can use to
+ // interact with the service. We are communicating with the
+ // service using a Messenger, so here we get a client-side
+ // representation of that from the raw IBinder object.
+ messenger = Messenger(service)
+ bound = true
+ // Request the data as soon as possible
+ requestData()
+ }
+
+ override fun onServiceDisconnected(className: ComponentName) {
+ Timber.tag(loggerTag.value).d("onServiceDisconnected")
+ // This is called when the connection with the service has been
+ // unexpectedly disconnected—that is, its process crashed.
+ messenger = null
+ bound = false
+ }
+ }
+
+ override fun start() {
+ Timber.tag(loggerTag.value).w("start()")
+ coroutineScope.launch {
+ // Establish a connection with the service. We use an explicit
+ // class name because there is no reason to be able to let other
+ // applications replace our component.
+ try {
+ val intentService = Intent()
+ intentService.setComponent(getElementClassicComponent(buildMeta))
+ if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) {
+ Timber.tag(loggerTag.value).d("Binding returned true")
+ } else {
+ // This happen when the app is not installed
+ Timber.tag(loggerTag.value).d("Binding returned false")
+ mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound)
+ }
+ } catch (e: SecurityException) {
+ Timber.tag(loggerTag.value).e(e, "Can't bind to Service")
+ mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
+ }
+ }
+ }
+
+ override fun stop() {
+ Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)")
+ if (bound) {
+ // Detach our existing connection.
+ context.unbindService(serviceConnection)
+ bound = false
+ }
+ coroutineScope.launch {
+ mutableStateFlow.emit(ElementClassicConnectionState.Idle)
+ }
+ }
+
+ override fun requestData() {
+ Timber.tag(loggerTag.value).w("requestData()")
+ coroutineScope.launch {
+ val finalMessenger = messenger
+ if (finalMessenger == null) {
+ Timber.tag(loggerTag.value).w("The messenger is null, can't request data")
+ mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data"))
+ } else {
+ try {
+ // Get the data
+ val msg = Message.obtain(null, MSG_GET_DATA)
+ msg.replyTo = incomingMessenger
+ finalMessenger.send(msg)
+ } catch (e: RemoteException) {
+ // In this case the service has crashed before we could even
+ // do anything with it; we can count on soon being
+ // disconnected (and then reconnected if it can be restarted)
+ // so there is no need to do anything here.
+ Timber.tag(loggerTag.value).e(e, "RemoteException")
+ mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty()))
+ }
+ }
+ }
+ }
+
+ private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle)
+ override val stateFlow = mutableStateFlow.asStateFlow()
+
+ /**
+ * Handler of incoming messages from service.
+ */
+ @Suppress("DEPRECATION")
+ inner class IncomingHandler : Handler() {
+ override fun handleMessage(msg: Message) {
+ Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}")
+ when (msg.what) {
+ MSG_GET_DATA -> {
+ // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied
+ val state = msg.data.toElementClassicConnectionState()
+ emitElementClassicState(state)
+ }
+ else -> {
+ super.handleMessage(msg)
+ }
+ }
+ }
+ }
+
+ private fun emitElementClassicState(state: ElementClassicConnectionState) = coroutineScope.launch {
+ when (state) {
+ is ElementClassicConnectionState.Error -> {
+ Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error)
+ mutableStateFlow.emit(state)
+ }
+ is ElementClassicConnectionState.ElementClassicReady -> {
+ Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId)
+ mutableStateFlow.emit(state)
+ }
+ ElementClassicConnectionState.ElementClassicReadyNoSession -> {
+ Timber.tag(loggerTag.value).d("Received no session from Element Classic")
+ mutableStateFlow.emit(state)
+ }
+ else -> {
+ // Should not happen
+ Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state)
+ mutableStateFlow.emit(ElementClassicConnectionState.Idle)
+ }
+ }
+ }
+
+ private fun getElementClassicComponent(buildMeta: BuildMeta) = ComponentName(
+ buildString {
+ append(ELEMENT_CLASSIC_APP_ID)
+ append(
+ when (buildMeta.buildType) {
+ BuildType.DEBUG -> ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX
+ BuildType.NIGHTLY -> ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX
+ BuildType.RELEASE -> ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX
+ }
+ )
+ },
+ ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME,
+ )
+
+ private fun Bundle?.toElementClassicConnectionState(): ElementClassicConnectionState {
+ return if (this == null) {
+ ElementClassicConnectionState.Error("No data received from Element Classic")
+ } else {
+ val error = getString(KEY_ERROR_STR)
+ if (error != null) {
+ ElementClassicConnectionState.Error(error)
+ } else {
+ val userId = getString(KEY_USER_ID_STR)?.let(::UserId)
+ if (userId != null) {
+ ElementClassicConnectionState.ElementClassicReady(userId)
+ } else {
+ ElementClassicConnectionState.ElementClassicReadyNoSession
+ }
+ }
+ }
+ }
+
+ // Everything in this companion object must match what is defined in Element Classic
+ private companion object {
+ // Command to the service to get the data.
+ const val MSG_GET_DATA = 1
+
+ const val ELEMENT_CLASSIC_APP_ID = "im.vector.app"
+ const val ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX = ".debug"
+ const val ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX = ".nightly"
+ const val ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX = ""
+
+ const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService"
+
+ // Keys for the bundle returned from the service
+ const val KEY_ERROR_STR = "error"
+ const val KEY_USER_ID_STR = "userId"
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt
new file mode 100644
index 00000000000..75a9496a027
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt
@@ -0,0 +1,15 @@
+/*
+ * 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.screens.onboarding.classic
+
+sealed interface LoginWithClassicEvent {
+ data object RefreshData : LoginWithClassicEvent
+ data object StartLoginWithClassic : LoginWithClassicEvent
+ data object DoLoginWithClassic : LoginWithClassicEvent
+ data object CloseDialog : LoginWithClassicEvent
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt
new file mode 100644
index 00000000000..ef352794cbd
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.screens.onboarding.classic
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.api.toUserListFlow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Inject
+class LoginWithClassicPresenter(
+ private val elementClassicConnection: ElementClassicConnection,
+ private val sessionStore: SessionStore,
+ private val featureFlagService: FeatureFlagService,
+) : Presenter {
+ @Composable
+ override fun present(): LoginWithClassicState {
+ val coroutineScope = rememberCoroutineScope()
+
+ val isSignInWithClassicEnabled by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic)
+ }.collectAsState(initial = false)
+
+ if (isSignInWithClassicEnabled) {
+ DisposableEffect(Unit) {
+ elementClassicConnection.start()
+ onDispose {
+ elementClassicConnection.stop()
+ }
+ }
+ }
+
+ val state by elementClassicConnection.stateFlow.collectAsState()
+ val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
+
+ val existingSession by remember {
+ sessionStore.sessionsFlow().toUserListFlow()
+ }.collectAsState(emptyList())
+
+ val canLoginWithClassic by remember {
+ derivedStateOf {
+ when (val finalState = state) {
+ is ElementClassicConnectionState.ElementClassicReady -> {
+ // Ensure there is no existing session with the same Id.
+ finalState.userId.value !in existingSession && isSignInWithClassicEnabled
+ }
+ else -> false
+ }
+ }
+ }
+
+ fun handleEvent(event: LoginWithClassicEvent) {
+ when (event) {
+ LoginWithClassicEvent.RefreshData -> {
+ elementClassicConnection.requestData()
+ }
+ LoginWithClassicEvent.StartLoginWithClassic -> {
+ val currentState = elementClassicConnection.stateFlow.value
+ if (currentState is ElementClassicConnectionState.ElementClassicReady) {
+ loginWithClassicAction.value = ConfirmingLoginWithElementClassic(
+ userId = currentState.userId,
+ )
+ } else {
+ loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready"))
+ }
+ }
+ LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch {
+ // TODO Implement real login logic here
+ loginWithClassicAction.value = AsyncAction.Loading
+ delay(1000)
+ loginWithClassicAction.value = AsyncAction.Success(Unit)
+ }
+ LoginWithClassicEvent.CloseDialog -> {
+ loginWithClassicAction.value = AsyncAction.Uninitialized
+ }
+ }
+ }
+
+ return LoginWithClassicState(
+ canLoginWithClassic = canLoginWithClassic,
+ loginWithClassicAction = loginWithClassicAction.value,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt
new file mode 100644
index 00000000000..d2706fc24a5
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt
@@ -0,0 +1,16 @@
+/*
+ * 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.screens.onboarding.classic
+
+import io.element.android.libraries.architecture.AsyncAction
+
+data class LoginWithClassicState(
+ val canLoginWithClassic: Boolean,
+ val loginWithClassicAction: AsyncAction,
+ val eventSink: (LoginWithClassicEvent) -> Unit,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt
new file mode 100644
index 00000000000..73f68e5d61b
--- /dev/null
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt
@@ -0,0 +1,20 @@
+/*
+ * 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.screens.onboarding.classic
+
+import io.element.android.libraries.architecture.AsyncAction
+
+fun aLoginWithClassicState(
+ canLoginWithClassic: Boolean = false,
+ loginWithClassicAction: AsyncAction = AsyncAction.Uninitialized,
+ eventSink: (LoginWithClassicEvent) -> Unit = {},
+) = LoginWithClassicState(
+ canLoginWithClassic = canLoginWithClassic,
+ loginWithClassicAction = loginWithClassicAction,
+ eventSink = eventSink,
+)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt
index 4da64480271..13888fe23fc 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt
@@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
@Inject
@@ -46,7 +46,7 @@ class QrCodeIntroPresenter(
canContinue = true
} else {
pendingPermissionRequest = true
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
index 4f444b14bcd..6fa3d1b0c7f 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt
@@ -106,7 +106,7 @@ private fun Content(
QrCodeCameraView(
modifier = Modifier.fillMaxSize(),
onScanQrCode = { state.eventSink.invoke(QrCodeScanEvents.QrCodeScanned(it)) },
- renderPreview = state.isScanning,
+ isScanning = state.isScanning,
)
}
}
diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml
index 6ffd9a84d4a..73f0dd51cce 100644
--- a/features/login/impl/src/main/res/values-cs/translations.xml
+++ b/features/login/impl/src/main/res/values-cs/translations.xml
@@ -60,6 +60,8 @@
"Žádost o přihlášení zrušena"
"Přihlášení bylo na druhém zařízení odmítnuto."
"Přihlášení odmítnuto"
+ "Nemusíte dělat nic jiného."
+ "Vaše další zařízení je již přihlášeno"
"Platnost přihlášení vypršela. Zkuste to prosím znovu."
"Přihlášení nebylo dokončeno včas"
"Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu.
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index cb3459f62b0..f1d426e1344 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -60,6 +60,8 @@
"Anmeldeanfrage abgebrochen"
"Die Anmeldung auf dem anderen Gerät wurde abgelehnt."
"Anmelden abgelehnt"
+ "Du musst nichts weiter tun."
+ "Dein anderes Gerät ist schon angemeldet."
"Die Anmeldung ist abgelaufen. Bitte versuche es erneut."
"Die Anmeldung wurde nicht rechtzeitig abgeschlossen"
"Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht.
diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml
index 3e3e6add375..0a1a99383d9 100644
--- a/features/login/impl/src/main/res/values-et/translations.xml
+++ b/features/login/impl/src/main/res/values-et/translations.xml
@@ -60,6 +60,8 @@
"Sisselogimispäring on tühistatud"
"Sisselogimisest on teises seadmes keeldutud."
"Sisselogimisest on keeldutud"
+ "Sa ei pea enam midagi muud tegema."
+ "Sinu muu seade on juba sisse logitud"
"Sisselogimine aegus. Palun proovi uuesti."
"Sisselogimine jäi etteantud aja jooksul tegemata"
"Sinu teine seade ei toeta %s sisselogimist QR-koodiga.
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index 946fec802ee..9846feec382 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -40,7 +40,7 @@
"Version %1$s"
"Se connecter manuellement"
"Connectez-vous à %1$s"
- "Se connecter avec un QR code"
+ "Se connecter avec un code QR"
"Créer un compte"
"Bienvenue dans l’application %1$s la plus rapide de tous les temps. Boosté pour plus de rapidité et de simplicité."
"Bienvenue sur %1$s. Boosté, pour plus de rapidité et de simplicité."
@@ -60,6 +60,8 @@
"Demande de connexion annulée"
"La connexion a été refusée sur l’autre appareil."
"Connexion refusée"
+ "Vous n’avez rien d’autre à faire."
+ "Votre autre appareil est déjà connecté"
"Connexion expirée. Veuillez essayer à nouveau."
"La connexion a pris trop de temps."
"Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil."
@@ -73,14 +75,14 @@
"“Associer une nouvelle session”"
"Scanner le code QR avec cet appareil"
"Disponible uniquement si votre fournisseur de compte le supporte."
- "Ouvrez %1$s sur un autre appareil pour obtenir le QR code"
- "Scannez le QR code affiché sur l’autre appareil."
+ "Ouvrez %1$s sur un autre appareil pour obtenir le code QR"
+ "Scannez le code QR affiché sur l’autre appareil."
"Essayer à nouveau"
- "QR code erroné"
+ "Code QR erroné"
"Accéder aux paramètres de l’appareil photo"
"Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."
"Autoriser l’usage de la caméra pour scanner le code QR"
- "Scannez le QR code"
+ "Scannez le code QR"
"Recommencer"
"Une erreur inattendue s’est produite. Veuillez réessayer."
"En attente de votre autre session"
diff --git a/features/login/impl/src/main/res/values-hr/translations.xml b/features/login/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..ced196f87ee
--- /dev/null
+++ b/features/login/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,100 @@
+
+
+ "Promijeni davatelja usluga računa"
+ "Adresa matičnog poslužitelja"
+ "Unesite pojam za pretraživanje ili adresu domene."
+ "Potražite tvrtku, zajednicu ili privatni poslužitelj."
+ "Pronađite davatelja usluga računa"
+ "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."
+ "Prijavit ćete se na %s"
+ "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."
+ "Izradit ćete račun na %s"
+ "Matrix.org velik je, besplatni poslužitelj na javnoj Matrixovoj mreži koji pruža sigurnu, decentraliziranu komunikaciju, a kojim upravlja zaklada Matrix.org."
+ "Ostalo"
+ "Koristite drugog davatelja računa, kao što je vlastiti privatni poslužitelj ili poslovni račun."
+ "Promijeni davatelja usluga računa"
+ "Google Play"
+ "Potrebna je aplikacija Element Pro na %1$s. Molimo vas da je preuzmete iz trgovine."
+ "Potreban je Element Pro"
+ "Nismo mogli pristupiti ovom matičnom poslužitelju. Provjerite jeste li ispravno unijeli URL matičnog poslužitelja. Ako je URL ispravan, obratite se administratoru matičnog poslužitelja za daljnju pomoć."
+ "Poslužitelj nije dostupan zbog problema u .well-known datoteci:
+%1$s"
+ "Odabrani davatelj usluga računa ne podržava sliding sync. Za korištenje je potrebna nadogradnja poslužitelja %1$ssliding sync."
+ "%1$s nije dopušteno povezivanje s %2$s."
+ "Ova je aplikacija konfigurirana tako da dopušta: %1$s."
+ "Davatelj usluga računa %1$s nije dopušten."
+ "URL matičnog poslužitelja"
+ "Unesite adresu domene."
+ "Koja je adresa vašeg poslužitelja?"
+ "Odaberite svoj poslužitelj"
+ "Izradi račun"
+ "Ovaj je račun deaktiviran."
+ "Netočno korisničko ime i/ili zaporka"
+ "To nije valjani identifikator korisnika. Očekivani oblik: ‘@korisnik:matičniposlužitelj.org’"
+ "Ovaj je poslužitelj konfiguriran za korištenje tokena za osvježavanje. Oni nisu podržani kada se upotrebljava prijava temeljena na zaporki."
+ "Odabrani matični poslužitelj ne podržava zaporku ili OIDC prijavu. Obratite se administratoru ili odaberite drugi matični poslužitelj."
+ "Unesite svoje podatke"
+ "Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju."
+ "Dobro došli natrag!"
+ "Prijavi se na poslužitelj %1$s"
+ "Inačica %1$s"
+ "Prijavi se ručno"
+ "Prijavi se na poslužitelj %1$s"
+ "Prijavi se pomoću QR koda"
+ "Izradi račun"
+ "Dobro došli u nikad brži %1$s. Snažniji no ikad za postizanje brzine i jednostavnosti."
+ "Dobro došli u %1$s. Snažniji no ikad – za brzinu i jednostavnost."
+ "Budi u elementu"
+ "Uspostavljanje sigurne veze"
+ "Nije moguće uspostaviti sigurnu vezu s novim uređajem. Vaši postojeći uređaji i dalje su sigurni i ne morate se brinuti zbog njih."
+ "Što sad?"
+ "Pokušajte se ponovno prijaviti pomoću QR koda u slučaju da se radilo o problemu s mrežom"
+ "Ako se problem ponovi, pokušajte s drugom Wi-Fi mrežom ili mobilnim podatcima umjesto Wi-Fi-ja."
+ "Ako to ne uspije, prijavite se ručno"
+ "Veza nije sigurna"
+ "Od vas će se zatražiti da unesete dvije znamenke prikazane na ovom uređaju."
+ "Unesite ispod navedeni broj u svoj drugi uređaj"
+ "Prijavite se na drugi uređaj i pokušajte ponovno ili upotrijebite drugi uređaj na kojem ste već prijavljeni."
+ "Niste prijavljeni na drugom uređaju"
+ "Prijava je otkazana na drugom uređaju."
+ "Zahtjev za prijavu je otkazan"
+ "Prijava je odbijena na drugom uređaju."
+ "Prijava je odbijena"
+ "Ne morate ništa drugo napraviti."
+ "Vaš drugi uređaj već je prijavljen"
+ "Prijava je istekla. Pokušajte ponovno."
+ "Prijava nije dovršena na vrijeme"
+ "Vaš drugi uređaj ne podržava prijavu na %s pomoću QR koda.
+
+Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem."
+ "QR kod nije podržan"
+ "Vaš davatelj usluga računa ne podržava %1$s ."
+ "%1$s nije podržan"
+ "Spremno za skeniranje"
+ "Otvorite %1$s na stolnom uređaju"
+ "Kliknite na svoj avatar"
+ "Odaberite %1$s"
+ "“Poveži novi uređaj”"
+ "Skenirajte QR kod ovim uređajem"
+ "Dostupno samo ako vaš davatelj usluge računa to podržava."
+ "Otvorite %1$s na drugom uređaju kako biste dobili QR kod"
+ "Upotrijebite QR kod prikazan na drugom uređaju."
+ "Pokušajte ponovno"
+ "Pogrešan QR kod"
+ "Idi na postavke kamere"
+ "Za nastavak morate dati dopuštenje za %1$s da biste se mogli služiti kamerom svog uređaja."
+ "Dopustite pristup kameri kako biste mogli skenirati QR kod"
+ "Skeniraj QR kod"
+ "Kreni ispočetka"
+ "Došlo je do neočekivane pogreške. Pokušajte ponovno."
+ "Čekanje na vaš drugi uređaj"
+ "Davatelj usluge računa može zatražiti sljedeći kod za potvrdu prijave."
+ "Vaš verifikacijski kod"
+ "Promijeni davatelja usluga računa"
+ "Privatni poslužitelj za zaposlenike aplikacije Element."
+ "Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju."
+ "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka."
+ "Prijavit ćete se na poslužitelj %1$s"
+ "Odaberite davatelja usluga računa"
+ "Izradit ćete račun na poslužitelju %1$s"
+
diff --git a/features/login/impl/src/main/res/values-pt-rBR/translations.xml b/features/login/impl/src/main/res/values-pt-rBR/translations.xml
index ce67264ae84..afca14d201a 100644
--- a/features/login/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/login/impl/src/main/res/values-pt-rBR/translations.xml
@@ -60,6 +60,8 @@
"Solicitação de entrada foi cancelada"
"A entrada foi recusada no outro dispositivo."
"Entrada recusada"
+ "Você não precisa fazer mais nada."
+ "O seu outro dispositivo já está conectado"
"O processo de entrada expirou. Tente novamente."
"A entrada não foi concluída a tempo"
"Seu outro dispositivo não tem suporte a entrar no %s com um código QR.
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index 9b235558c84..832c3b7f71d 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -60,6 +60,8 @@
"Sign in request cancelled"
"The sign in was declined on the other device."
"Sign in declined"
+ "You don’t need to do anything else."
+ "Your other device is already signed in"
"Sign in expired. Please try again."
"The sign in was not completed in time"
"Your other device does not support signing in to %s with a QR code.
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
index 202400a8668..600cf338d72 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt
@@ -16,8 +16,6 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@@ -80,14 +78,11 @@ class CreateAccountPresenterTest {
fun `present - receiving a message able to be parsed change the state to success`() = runTest {
val lambda = lambdaRecorder { _ -> anExternalSession() }
val sessionVerificationService = FakeSessionVerificationService()
- val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService)
- val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val presenter = createPresenter(
authenticationService = FakeMatrixAuthenticationService(
importCreatedSessionLambda = { Result.success(A_SESSION_ID) }
),
messageParser = FakeMessageParser(lambda),
- clientProvider = clientProvider,
)
presenter.test {
val initialState = awaitItem()
@@ -120,12 +115,10 @@ class CreateAccountPresenterTest {
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
messageParser: MessageParser = FakeMessageParser(),
buildMeta: BuildMeta = aBuildMeta(),
- clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
) = CreateAccountPresenter(
url = url,
authenticationService = authenticationService,
messageParser = messageParser,
buildMeta = buildMeta,
- clientProvider = clientProvider,
)
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
index 1d434997ca4..1e971ef2656 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt
@@ -16,6 +16,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginHelper
+import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState
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
@@ -88,7 +89,10 @@ class OnBoardingPresenterTest {
assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT)
assertThat(initialState.canReportBug).isFalse()
assertThat(initialState.isAddingAccount).isFalse()
- assertThat(awaitItem().canLoginWithQrCode).isTrue()
+ assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse()
+ val finalState = awaitItem()
+ assertThat(finalState.canLoginWithQrCode).isTrue()
+ assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse()
}
}
@@ -283,6 +287,7 @@ private fun createPresenter(
onBoardingLogoResIdProvider = onBoardingLogoResIdProvider,
sessionStore = sessionStore,
accountProviderDataSource = accountProviderDataSource,
+ loginWithClassicPresenter = { aLoginWithClassicState() },
)
fun createLoginHelper(
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt
new file mode 100644
index 00000000000..2c41d2ed0f5
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.screens.onboarding.classic
+
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class FakeElementClassicConnection(
+ private val startResult: () -> Unit = { lambdaError() },
+ private val stopResult: () -> Unit = { lambdaError() },
+ private val requestDataResult: () -> Unit = { lambdaError() },
+ initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
+) : ElementClassicConnection {
+ override fun start() = startResult()
+ override fun stop() = stopResult()
+ override fun requestData() = requestDataResult()
+ private val mutableStateFlow = MutableStateFlow(initialState)
+ override val stateFlow: StateFlow = mutableStateFlow.asStateFlow()
+ suspend fun emitState(state: ElementClassicConnectionState) {
+ mutableStateFlow.emit(state)
+ }
+}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt
new file mode 100644
index 00000000000..8a8e4985c9a
--- /dev/null
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt
@@ -0,0 +1,213 @@
+/*
+ * 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(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.login.impl.screens.onboarding.classic
+
+import com.google.common.truth.Truth.assertThat
+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.test.A_USER_ID
+import io.element.android.libraries.sessionstorage.api.SessionStore
+import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
+import io.element.android.libraries.sessionstorage.test.aSessionData
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LoginWithClassicPresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state - feature disabled - start is not invoked`() = runTest {
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = {
+ error("start should not be invoked when feature is disabled")
+ },
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.canLoginWithClassic).isFalse()
+ assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - feature enabled - start is invoked`() = runTest {
+ val startResult = lambdaRecorder {}
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = startResult,
+ ),
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.canLoginWithClassic).isFalse()
+ assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.canLoginWithClassic).isFalse()
+ }
+ startResult.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `present - emit request data invokes the expected method`() = runTest {
+ val requestDataResult = lambdaRecorder {}
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ requestDataResult = requestDataResult,
+ ),
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.canLoginWithClassic).isFalse()
+ assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue()
+ val nextState = awaitItem()
+ assertThat(nextState.canLoginWithClassic).isFalse()
+ nextState.eventSink(LoginWithClassicEvent.RefreshData)
+ }
+ requestDataResult.assertions().isCalledOnce()
+ }
+
+ @Test
+ fun `present - start login with wrong state emits an error`() = runTest {
+ val presenter = createPresenter(
+ elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ ),
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ state.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
+ val errorState = awaitItem()
+ assertThat(errorState.loginWithClassicAction.isFailure()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - start login with correct state - user cancel`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ )
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ skipItems(2)
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ val readyState = awaitItem()
+ assertThat(readyState.canLoginWithClassic).isTrue()
+ readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue()
+ assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID)
+ confirmingState.eventSink(LoginWithClassicEvent.CloseDialog)
+ val finalState = awaitItem()
+ assertThat(finalState.loginWithClassicAction.isUninitialized()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - start login with correct state - user confirms`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ )
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = true,
+ )
+ presenter.test {
+ skipItems(2)
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ val readyState = awaitItem()
+ assertThat(readyState.canLoginWithClassic).isTrue()
+ readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic)
+ val confirmingState = awaitItem()
+ assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue()
+ assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID)
+ confirmingState.eventSink(LoginWithClassicEvent.DoLoginWithClassic)
+ val loadingState = awaitItem()
+ assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.loginWithClassicAction.isSuccess()).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - cannot sign in if a session with the same account already exists`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection(
+ startResult = {},
+ )
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = true,
+ sessionStore = InMemorySessionStore(
+ initialList = listOf(
+ aSessionData(
+ sessionId = A_USER_ID.value,
+ )
+ )
+ ),
+ )
+ presenter.test {
+ skipItems(2)
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ // No new item, because canLoginWithClassic is still false
+ }
+ }
+
+ @Test
+ fun `present - cannot sign in if the feature is disabled`() = runTest {
+ val elementClassicConnection = FakeElementClassicConnection()
+ val presenter = createPresenter(
+ elementClassicConnection = elementClassicConnection,
+ isFeatureEnabled = false,
+ )
+ presenter.test {
+ skipItems(1)
+ // Note: it should not happen IRL
+ elementClassicConnection.emitState(
+ ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID)
+ )
+ // No new item, because canLoginWithClassic is still false
+ }
+ }
+}
+
+private fun createPresenter(
+ elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
+ sessionStore: SessionStore = InMemorySessionStore(),
+ isFeatureEnabled: Boolean = false,
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.SignInWithClassic.key to isFeatureEnabled)
+ ),
+) = LoginWithClassicPresenter(
+ elementClassicConnection = elementClassicConnection,
+ sessionStore = sessionStore,
+ featureFlagService = featureFlagService,
+)
diff --git a/features/logout/impl/src/main/res/values-hr/translations.xml b/features/logout/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..331eca04c47
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Jeste li sigurni da se želite odjaviti?"
+ "Odjava"
+ "Odjava"
+ "Odjavljivanje…"
+ "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, nećete moći pristupiti svojim šifriranim porukama."
+ "Isključili ste sigurnosno kopiranje"
+ "Vaši su se ključevi još uvijek sigurnosno kopirali kada ste se isključili iz mreže. Ponovno se povežite kako bi se vaši ključevi mogli sigurnosno kopirati prije nego što se odjavite."
+ "Vaši se ključevi još uvijek sigurnosno kopiraju"
+ "Pričekajte da se to dovrši prije nego što se odjavite."
+ "Vaši se ključevi još uvijek sigurnosno kopiraju"
+ "Odjava"
+ "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, nećete moći pristupiti svojim šifriranim porukama."
+ "Oporavak nije postavljen"
+ "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, možda nećete moći pristupiti svojim šifriranim porukama."
+ "Jeste li spremili svoj ključ za oporavak?"
+
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index e6a1299d55f..bd3e2aaf290 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -71,6 +71,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
+ implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index f753a19d1f8..9d582aaaf16 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -12,12 +12,10 @@ import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -32,6 +30,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
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.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent
@@ -75,14 +74,10 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.isDm
-import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
-import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
-import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
-import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.map
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -107,6 +102,7 @@ class MessagesPresenter(
@Assisted private val timelinePresenter: Presenter,
private val timelineProtectionPresenter: Presenter,
private val identityChangeStatePresenter: Presenter,
+ private val historyVisibleStatePresenter: Presenter,
private val linkPresenter: Presenter,
@Assisted private val actionListPresenter: Presenter,
private val customReactionPresenter: Presenter,
@@ -158,6 +154,7 @@ class MessagesPresenter(
val timelineState = timelinePresenter.present()
val timelineProtectionState = timelineProtectionPresenter.present()
val identityChangeState = identityChangeStatePresenter.present()
+ val historyVisibleState = historyVisibleStatePresenter.present()
val actionListState = actionListPresenter.present()
val linkState = linkPresenter.present()
val customReactionState = customReactionPresenter.present()
@@ -167,7 +164,9 @@ class MessagesPresenter(
val roomCallState = roomCallStatePresenter.present()
val roomMemberModerationState = roomMemberModerationPresenter.present()
- val userEventPermissions by userEventPermissions(roomInfo)
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val roomAvatar by remember {
derivedStateOf { roomInfo.avatarData() }
@@ -287,6 +286,7 @@ class MessagesPresenter(
timelineState = timelineState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
+ historyVisibleState = historyVisibleState,
linkState = linkState,
actionListState = actionListState,
customReactionState = customReactionState,
@@ -306,24 +306,6 @@ class MessagesPresenter(
)
}
- @Composable
- private fun userEventPermissions(roomInfo: RoomInfo): State {
- val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) {
- Long.MAX_VALUE
- } else {
- roomInfo.roomPowerLevels?.hashCode() ?: 0L
- }
- return produceState(UserEventPermissions.DEFAULT, key1 = key) {
- value = UserEventPermissions(
- canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).getOrElse { true },
- canSendReaction = room.canSendMessage(type = MessageEventType.Reaction).getOrElse { true },
- canRedactOwn = room.canRedactOwn().getOrElse { false },
- canRedactOther = room.canRedactOther().getOrElse { false },
- canPinUnpin = room.canPinUnpin().getOrElse { false },
- )
- }
- }
-
private fun RoomInfo.avatarData(): AvatarData {
return AvatarData(
id = id.value,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index ea147b3cff3..3099edc9c27 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.actionlist.ActionListState
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
@@ -47,6 +48,7 @@ data class MessagesState(
val timelineState: TimelineState,
val timelineProtectionState: TimelineProtectionState,
val identityChangeState: IdentityChangeState,
+ val historyVisibleState: HistoryVisibleState,
val linkState: LinkState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index 1c55a1035ff..1177d82eecb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -14,7 +14,10 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
+import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
+import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.link.aLinkState
@@ -38,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
+import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@@ -48,6 +52,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -83,6 +88,19 @@ open class MessagesStateProvider : PreviewParameterProvider {
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
)
),
+ aMessagesState(
+ composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
+ identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange()))
+ ),
+ aMessagesState(
+ composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
+ historyVisibleState = aHistoryVisibleState(showAlert = true)
+ ),
+ aMessagesState(
+ composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()),
+ identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())),
+ historyVisibleState = aHistoryVisibleState(showAlert = true)
+ )
)
}
@@ -103,6 +121,7 @@ fun aMessagesState(
),
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
identityChangeState: IdentityChangeState = anIdentityChangeState(),
+ historyVisibleState: HistoryVisibleState = aHistoryVisibleState(),
linkState: LinkState = aLinkState(),
readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(),
actionListState: ActionListState = anActionListState(),
@@ -130,6 +149,7 @@ fun aMessagesState(
voiceMessageComposerState = voiceMessageComposerState,
timelineProtectionState = timelineProtectionState,
identityChangeState = identityChangeState,
+ historyVisibleState = historyVisibleState,
linkState = linkState,
timelineState = timelineState,
readReceiptBottomSheetState = readReceiptBottomSheetState,
@@ -150,11 +170,9 @@ fun aMessagesState(
)
fun aRoomMemberModerationState(
- canKick: Boolean = false,
- canBan: Boolean = false,
+ permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT,
) = object : RoomMemberModerationState {
- override val canKick: Boolean = canKick
- override val canBan: Boolean = canBan
+ override val permissions: RoomMemberModerationPermissions = permissions
override val eventSink: (RoomMemberModerationEvents) -> Unit = {}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index 0ff03ada041..d5754d206da 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -29,10 +29,15 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@@ -41,6 +46,7 @@ import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -48,6 +54,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStateView
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView
import io.element.android.features.messages.impl.link.LinkEvents
import io.element.android.features.messages.impl.link.LinkView
@@ -62,6 +69,10 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan
import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelineView
+import io.element.android.features.messages.impl.timeline.aGroupedEvents
+import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator
+import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
+import io.element.android.features.messages.impl.timeline.aTimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents
@@ -69,6 +80,9 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet
import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.topbars.MessagesViewTopBar
import io.element.android.features.messages.impl.topbars.ThreadTopBar
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
@@ -82,6 +96,7 @@ import io.element.android.libraries.designsystem.components.rememberExpandableBo
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toAnnotatedString
+import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
@@ -96,10 +111,12 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.wysiwyg.link.Link
+import kotlinx.collections.immutable.persistentListOf
import timber.log.Timber
import kotlin.time.Duration.Companion.milliseconds
@@ -129,6 +146,8 @@ fun MessagesView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
+ var maxComposerHeightPx by remember { mutableIntStateOf(120) }
+
// This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose
val localView = LocalView.current
@@ -179,7 +198,13 @@ fun MessagesView(
modifier = modifier
.fillMaxSize()
.imePadding()
- .systemBarsPadding(),
+ .systemBarsPadding()
+ .onSizeChanged { size ->
+ // Let the composer takes at max half of the available height.
+ // The value will be different if the soft keyboard is displayed
+ // or not.
+ maxComposerHeightPx = (size.height * 0.5f).toInt()
+ },
content = {
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
@@ -315,7 +340,7 @@ fun MessagesView(
} else {
RectangleShape
},
- maxBottomSheetContentHeight = 360.dp,
+ maxBottomSheetContentHeight = maxComposerHeightPx.toDp(),
)
ActionListView(
@@ -474,11 +499,18 @@ private fun MessagesViewComposerBottomSheetContents(
// Do not show the identity change if user is composing a Rich message or is seeing suggestion(s).
if (state.composerState.suggestions.isEmpty() &&
state.composerState.textEditorState is TextEditorState.Markdown) {
- IdentityChangeStateView(
- showMatrixId = state.showMatrixId,
- state = state.identityChangeState,
- onLinkClick = onLinkClick,
- )
+ if (state.identityChangeState.roomMemberIdentityStateChanges.isNotEmpty()) {
+ IdentityChangeStateView(
+ showMatrixId = state.showMatrixId,
+ state = state.identityChangeState,
+ onLinkClick = onLinkClick,
+ )
+ } else {
+ HistoryVisibleStateView(
+ state = state.historyVisibleState,
+ onLinkClick = onLinkClick,
+ )
+ }
}
val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull {
it.identityState == IdentityState.VerificationViolation
@@ -553,3 +585,57 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
knockRequestsBannerView = {},
)
}
+
+@Preview
+@Composable
+internal fun MessagesViewA11yPreview() = ElementPreview {
+ val content = aTimelineItemTextContent(
+ body = "A message content"
+ )
+ MessagesView(
+ state = aMessagesState(
+ roomName = "A DM with a very looong name",
+ dmUserVerificationState = IdentityState.VerificationViolation,
+ timelineState = aTimelineState(
+ timelineItems = persistentListOf(
+ // 1 items with isMine = false
+ aTimelineItemEvent(
+ isMine = false,
+ content = content,
+ groupPosition = TimelineItemGroupPosition.None,
+ sendState = LocalEventSendState.Failed.Unknown("Message failed to send"),
+ ),
+ // A state event on top of it
+ aTimelineItemEvent(
+ isMine = false,
+ content = aTimelineItemStateEventContent(),
+ groupPosition = TimelineItemGroupPosition.None
+ ),
+ // 1 item with isMine = true
+ aTimelineItemEvent(
+ isMine = true,
+ content = content,
+ groupPosition = TimelineItemGroupPosition.None
+ ),
+ // A grouped event on top of it
+ aGroupedEvents(),
+ // A day separator
+ aTimelineItemDaySeparator(),
+ ),
+ // Render a focused event for an event with sender information displayed
+ focusedEventIndex = 2,
+ )
+ ),
+ onBackClick = {},
+ onRoomDetailsClick = {},
+ onEventContentClick = { _, _ -> false },
+ onUserDataClick = {},
+ onLinkClick = { _, _ -> },
+ onSendLocationClick = {},
+ onCreatePollClick = {},
+ onJoinCallClick = {},
+ onViewAllPinnedMessagesClick = { },
+ forceJumpToBottomVisibility = true,
+ knockRequestsBannerView = {},
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt
index f7d221950b8..349c8e58dc2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt
@@ -8,6 +8,9 @@
package io.element.android.features.messages.impl
+import io.element.android.libraries.matrix.api.room.MessageEventType
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
+
/**
* Represents the permissions a user has in a room.
* It's dependent of the user's power level in the room.
@@ -29,3 +32,13 @@ data class UserEventPermissions(
)
}
}
+
+fun RoomPermissions.userEventPermissions(): UserEventPermissions {
+ return UserEventPermissions(
+ canRedactOwn = canOwnUserRedactOwn(),
+ canRedactOther = canOwnUserRedactOther(),
+ canSendMessage = canOwnUserSendMessage(MessageEventType.RoomMessage),
+ canSendReaction = canOwnUserSendMessage(MessageEventType.Reaction),
+ canPinUnpin = canOwnUserPinUnpin()
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
index d7e0332bd4b..f7641ec21bb 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt
@@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
+import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
@@ -62,6 +63,7 @@ class AttachmentsPreviewPresenter(
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
+ private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -107,13 +109,9 @@ class AttachmentsPreviewPresenter(
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
- val mediaOptimizationConfig = MediaOptimizationConfig(
- compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true,
- videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
- )
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
- mediaOptimizationConfig = mediaOptimizationConfig,
+ mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
displayProgress = false,
sendActionState = sendActionState,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
index 6823aead3fd..70d7ab006ef 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt
@@ -95,7 +95,7 @@ fun aMediaUploadInfo(
)
fun aMediaOptimisationSelectorState(
- maxUploadSize: Long = 100,
+ maxUploadSize: Long = 100 * 1024 * 1024,
videoSizeEstimations: AsyncData> = AsyncData.Success(persistentListOf()),
isImageOptimizationEnabled: Boolean = true,
selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
index 7c9ffdaf89f..8f55957e327 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
@@ -231,7 +231,7 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
- style = ElementTheme.materialTypography.bodyLarge,
+ style = ElementTheme.typography.fontBodyLgRegular,
)
Switch(
modifier = Modifier.height(32.dp),
@@ -337,7 +337,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = {
Text(
text = preset.subtitle(),
- style = ElementTheme.materialTypography.bodyMedium,
+ style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt
index d0716abe839..c81c306f90a 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt
@@ -25,13 +25,12 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
+import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.compressorHelper
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
-import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.flow.first
import timber.log.Timber
import kotlin.math.roundToLong
@@ -39,8 +38,8 @@ import kotlin.math.roundToLong
class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val localMedia: LocalMedia,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
- private val sessionPreferencesStore: SessionPreferencesStore,
private val featureFlagService: FeatureFlagService,
+ private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class)
@@ -124,11 +123,12 @@ class DefaultMediaOptimizationSelectorPresenter(
var selectedVideoOptimizationPreset by remember { mutableStateOf>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
- selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first())
+ val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
+ selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
// Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset(
- defaultVideoPreset = sessionPreferencesStore.getVideoCompressionPreset().first(),
+ defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
videoSizeEstimations = videoSizeEstimations,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt
new file mode 100644
index 00000000000..1fa992fc3e3
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.androidutils.hash.hash
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+interface HistoryVisibleAcknowledgementRepository {
+ fun hasAcknowledged(roomId: RoomId): Flow
+ suspend fun setAcknowledged(roomId: RoomId, value: Boolean)
+}
+
+@ContributesBinding(SessionScope::class)
+class DefaultHistoryVisibleAcknowledgementRepository(
+ sessionId: SessionId,
+ preferenceDataStoreFactory: PreferenceDataStoreFactory,
+) : HistoryVisibleAcknowledgementRepository {
+ val store =
+ sessionId.value.hash().take(16).let { hash ->
+ preferenceDataStoreFactory.create("elementx_historyvisible_$hash")
+ }
+
+ override fun hasAcknowledged(roomId: RoomId): Flow {
+ return store.data.map { prefs ->
+ val acknowledged = prefs[booleanPreferencesKey(roomId.value)] ?: false
+ acknowledged
+ }
+ }
+
+ override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
+ store.edit { prefs ->
+ prefs[booleanPreferencesKey(roomId.value)] = value
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt
new file mode 100644
index 00000000000..775d9c00d42
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+sealed interface HistoryVisibleEvent {
+ data object Acknowledge : HistoryVisibleEvent
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt
new file mode 100644
index 00000000000..3f980eb0864
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+data class HistoryVisibleState(
+ val showAlert: Boolean,
+ val eventSink: (HistoryVisibleEvent) -> Unit,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt
new file mode 100644
index 00000000000..e79681cd5cb
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+@Inject
+class HistoryVisibleStatePresenter(
+ private val featureFlagService: FeatureFlagService,
+ private val repository: HistoryVisibleAcknowledgementRepository,
+ private val room: JoinedRoom,
+) : Presenter {
+ @Composable
+ override fun present(): HistoryVisibleState {
+ val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
+ val roomInfo by room.roomInfoFlow.collectAsState()
+ // Implicitly assume the alert is initially acknowledged to avoid flashes in UI.
+ val acknowledged by repository.hasAcknowledged(room.roomId).collectAsState(initial = true)
+ val isHistoryVisible = roomInfo.historyVisibility == RoomHistoryVisibility.Shared || roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable
+
+ val coroutineScope = rememberCoroutineScope()
+
+ LaunchedEffect(isHistoryVisible, acknowledged) {
+ if (!isHistoryVisible && acknowledged) {
+ // Clear the dismissed flag, if it is set to ensure that if a room is changed public -> private -> public,
+ // we show the banner again when it is set back to public.
+ repository.setAcknowledged(room.roomId, false)
+ }
+ }
+
+ fun handleEvent(event: HistoryVisibleEvent) {
+ when (event) {
+ is HistoryVisibleEvent.Acknowledge -> coroutineScope.setAcknowledged(room.roomId, true)
+ }
+ }
+
+ return HistoryVisibleState(
+ showAlert = isFeatureEnabled && isHistoryVisible && roomInfo.isEncrypted == true && !acknowledged,
+ eventSink = ::handleEvent,
+ )
+ }
+
+ private fun CoroutineScope.setAcknowledged(roomId: RoomId, value: Boolean) = launch {
+ repository.setAcknowledged(roomId, value)
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt
new file mode 100644
index 00000000000..752abdc76b7
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+class HistoryVisibleStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aHistoryVisibleState(showAlert = true),
+ )
+}
+
+internal fun aHistoryVisibleState(
+ showAlert: Boolean = false,
+ eventSink: (HistoryVisibleEvent) -> Unit = {},
+) = HistoryVisibleState(
+ showAlert,
+ eventSink = eventSink,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt
new file mode 100644
index 00000000000..d0655f695d3
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.appconfig.LearnMoreConfig
+import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel
+import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.text.stringWithLink
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun HistoryVisibleStateView(
+ state: HistoryVisibleState,
+ onLinkClick: (String, Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (!state.showAlert) {
+ return
+ }
+ ComposerAlertMolecule(
+ modifier = modifier,
+ avatar = null,
+ showIcon = true,
+ level = ComposerAlertLevel.Info,
+ content = stringWithLink(
+ textRes = CommonStrings.crypto_history_visible,
+ url = LearnMoreConfig.HISTORY_VISIBLE_URL,
+ onLinkClick = { url -> onLinkClick(url, true) },
+ ),
+ submitText = stringResource(CommonStrings.action_dismiss),
+ onSubmitClick = { state.eventSink(HistoryVisibleEvent.Acknowledge) },
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun HistoryVisibleStateViewPreview(
+ @PreviewParameter(HistoryVisibleStateProvider::class) state: HistoryVisibleState,
+) = ElementPreview {
+ HistoryVisibleStateView(
+ state = state,
+ onLinkClick = { _, _ -> },
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt
new file mode 100644
index 00000000000..07cf5170d3a
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import androidx.compose.runtime.Composable
+import io.element.android.features.messages.impl.MessagesView
+import io.element.android.features.messages.impl.aMessagesState
+import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
+
+@PreviewsDayNight
+@Composable
+internal fun MessagesViewWithHistoryVisiblePreview() = ElementPreview {
+ MessagesView(
+ state = aMessagesState(
+ composerState = aMessageComposerState(
+ textEditorState = aTextEditorStateMarkdown(
+ initialText = "",
+ initialFocus = false,
+ )
+ ),
+ historyVisibleState = aHistoryVisibleState(showAlert = true),
+ ),
+ onBackClick = {},
+ onRoomDetailsClick = {},
+ onEventContentClick = { _, _ -> false },
+ onUserDataClick = {},
+ onLinkClick = { _, _ -> },
+ onSendLocationClick = {},
+ onCreatePollClick = {},
+ onJoinCallClick = {},
+ onViewAllPinnedMessagesClick = {},
+ knockRequestsBannerView = {}
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
index a345e09fa29..a88dbb1b494 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt
@@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState
+import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStatePresenter
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState
import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter
@@ -61,4 +63,7 @@ interface MessagesBindsModule {
@Binds
fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter
+
+ @Binds
+ fun bindHistoryVisibleStatePresenter(presenter: HistoryVisibleStatePresenter): Presenter
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index 276499d5bbb..fd803109c75 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import io.element.android.libraries.matrix.api.room.getDirectRoomMember
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.powerlevels.use
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@@ -63,7 +64,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
@@ -98,6 +99,7 @@ import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
+@Suppress("LargeClass")
@AssistedInject
class MessageComposerPresenter(
@Assisted private val navigator: MessagesNavigator,
@@ -284,7 +286,7 @@ class MessageComposerPresenter(
cameraPhotoPicker.launch()
} else {
pendingEvent = event
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
@@ -293,7 +295,7 @@ class MessageComposerPresenter(
cameraVideoPicker.launch()
} else {
pendingEvent = event
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
MessageComposerEvent.PickAttachmentSource.Location -> {
@@ -396,7 +398,9 @@ class MessageComposerPresenter(
val currentUserId = room.sessionId
suspend fun canSendRoomMention(): Boolean {
- val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
+ val userCanSendAtRoom = room.roomPermissions().use(false) { perms ->
+ perms.canOwnUserTriggerRoomNotification()
+ }
return !room.isDm() && userCanSendAtRoom
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
index 57af770d5b2..292a77ba6ab 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt
@@ -15,6 +15,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -100,6 +101,7 @@ class PinnedMessagesListNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val context = LocalContext.current
+ val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
val state = presenter.present()
PinnedMessagesListView(
@@ -113,8 +115,8 @@ class PinnedMessagesListNode(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
- it.url,
- context.getString(CommonStrings.common_copied_to_clipboard)
+ text = it.url,
+ toastMessage = toastMessage,
)
},
modifier = modifier
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
index cdc1f85f1d8..b2d2caa7f91 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt
@@ -10,11 +10,10 @@ package io.element.android.features.messages.impl.pinned.list
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
@@ -35,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.typing.TypingNotificationState
+import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -44,11 +44,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin
-import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
-import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
+import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
-import io.element.android.libraries.matrix.ui.room.isDmAsState
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -97,31 +95,33 @@ class PinnedMessagesListPresenter(
@Composable
override fun present(): PinnedMessagesListState {
htmlConverterProvider.Update()
- val isDm by room.isDmAsState()
-
- val timelineRoomInfo = remember(isDm) {
- TimelineRoomInfo(
- isDm = isDm,
- name = room.info().name,
- // We don't need to compute those values
- userHasPermissionToSendMessage = false,
- userHasPermissionToSendReaction = false,
- // We do not care about the call state here.
- roomCallState = aStandByCallState(),
- // don't compute this value or the pin icon will be shown
- pinnedEventIds = persistentListOf(),
- typingNotificationState = TypingNotificationState(
- renderTypingNotifications = false,
- typingMembers = persistentListOf(),
- reserveSpace = false,
- ),
- predecessorRoom = room.predecessorRoom(),
- )
+ val roomInfo by room.roomInfoFlow.collectAsState()
+ val timelineRoomInfo by remember {
+ derivedStateOf {
+ TimelineRoomInfo(
+ isDm = roomInfo.isDm,
+ name = roomInfo.name,
+ // We don't need to compute those values
+ userHasPermissionToSendMessage = false,
+ userHasPermissionToSendReaction = false,
+ // We do not care about the call state here.
+ roomCallState = aStandByCallState(),
+ // don't compute this value or the pin icon will be shown
+ pinnedEventIds = persistentListOf(),
+ typingNotificationState = TypingNotificationState(
+ renderTypingNotifications = false,
+ typingMembers = persistentListOf(),
+ reserveSpace = false,
+ ),
+ predecessorRoom = room.predecessorRoom(),
+ )
+ }
}
val timelineProtectionState = timelineProtectionPresenter.present()
val linkState = linkPresenter.present()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
- val userEventPermissions by userEventPermissions(syncUpdateFlow.value)
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false)
@@ -192,19 +192,6 @@ class PinnedMessagesListPresenter(
}
}
- @Composable
- private fun userEventPermissions(updateKey: Long): State {
- return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) {
- value = UserEventPermissions(
- canSendMessage = false,
- canSendReaction = false,
- canRedactOwn = room.canRedactOwn().getOrElse { false },
- canRedactOther = room.canRedactOther().getOrElse { false },
- canPinUnpin = room.canPinUnpin().getOrElse { false },
- )
- }
- }
-
@Composable
private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) {
val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 909c32b52a6..7cb28068481 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -25,6 +25,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.MessagesNavigator
+import io.element.android.features.messages.impl.UserEventPermissions
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents
import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@@ -41,12 +42,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemBwiScanStateChangedModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel
import io.element.android.features.messages.impl.typing.TypingNotificationState
+import io.element.android.features.messages.impl.userEventPermissions
import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -55,20 +56,20 @@ 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.core.asEventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.isDm
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
-import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
-import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
+import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
+import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -105,6 +106,7 @@ class TimelinePresenter(
private val analyticsService: AnalyticsService,
) : Presenter {
private val tag = "TimelinePresenter"
+
@AssistedFactory
interface Factory {
fun create(
@@ -138,11 +140,6 @@ class TimelinePresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
- val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
-
- val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.RoomMessage, updateKey = syncUpdateFlow.value)
- val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.Reaction, updateKey = syncUpdateFlow.value)
-
val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) }
val newEventState = remember { mutableStateOf(NewEventState.None) }
@@ -218,7 +215,7 @@ class TimelinePresenter(
}.start()
is TimelineEvents.OnFocusEventRender -> {
// If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event
- analyticsService.finishLongRunningTransaction(NotificationTapOpensTimeline)
+ analyticsService.finishLongRunningTransaction(NotificationToMessage)
focusRequestState.value = focusRequestState.value.onFocusEventRender()
}
@@ -263,7 +260,7 @@ class TimelinePresenter(
combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState ->
val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems)
val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items")
- transaction?.setData("items", items.count())
+ transaction?.putExtraData(AnalyticsUserData.TIMELINE_ITEM_COUNT, items.count().toString())
timelineItemsFactory.replaceWith(
timelineItems = items,
roomMembers = membersState.roomMembers().orEmpty()
@@ -295,13 +292,16 @@ class TimelinePresenter(
val typingNotificationState = typingNotificationPresenter.present()
val roomCallState = roomCallStatePresenter.present()
+ val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
+ perms.userEventPermissions()
+ }
val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) {
derivedStateOf {
TimelineRoomInfo(
name = roomInfo.name,
- isDm = roomInfo.isDm.orFalse(),
- userHasPermissionToSendMessage = userHasPermissionToSendMessage,
- userHasPermissionToSendReaction = userHasPermissionToSendReaction,
+ isDm = roomInfo.isDm,
+ userHasPermissionToSendMessage = userEventPermissions.canSendMessage,
+ userHasPermissionToSendReaction = userEventPermissions.canSendReaction,
roomCallState = roomCallState,
pinnedEventIds = roomInfo.pinnedEventIds,
typingNotificationState = typingNotificationState,
@@ -504,9 +504,8 @@ class TimelinePresenter(
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewEvent) {
- val newMostRecentEvent = newMostRecentItem
// Scroll to bottom if the new event is from me, even if sent from another device
- val fromMe = newMostRecentEvent?.isMine == true
+ val fromMe = newMostRecentItem.isMine
newEventState.value = if (fromMe) {
NewEventState.FromMe
} else {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index b3f69f0e2bf..73a15b797fa 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -123,6 +123,7 @@ fun TimelineView(
}
val context = LocalContext.current
+ val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = !isTalkbackActive()
@@ -136,8 +137,8 @@ fun TimelineView(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
- link.url,
- context.getString(CommonStrings.common_copied_to_clipboard)
+ text = link.url,
+ toastMessage = toastMessage,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
index a3f214f979c..5dbd0c478f1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
@@ -9,6 +9,7 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -42,6 +43,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
+import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -51,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
-import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
+import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import kotlinx.coroutines.delay
@@ -64,26 +66,26 @@ fun TimelineItemVoiceView(
modifier: Modifier = Modifier,
) {
fun playPause() {
- state.eventSink(VoiceMessageEvents.PlayPause)
+ state.eventSink(VoiceMessageEvent.PlayPause)
}
val a11y = stringResource(CommonStrings.common_voice_message)
val a11yActionLabel = stringResource(
- when (state.button) {
- VoiceMessageState.Button.Play -> CommonStrings.a11y_play
- VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
- VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
- VoiceMessageState.Button.Retry -> CommonStrings.action_retry
- VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
+ when (state.buttonType) {
+ VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play
+ VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause
+ VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading
+ VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry
+ VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown
}
)
Row(
modifier = modifier
.clearAndSetSemantics {
contentDescription = a11y
- if (state.button == VoiceMessageState.Button.Disabled) {
+ if (state.buttonType == VoiceMessageState.ButtonType.Disabled) {
disabled()
- } else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
+ } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) {
onClick(label = a11yActionLabel) {
playPause()
true
@@ -101,30 +103,41 @@ fun TimelineItemVoiceView(
verticalAlignment = Alignment.CenterVertically,
) {
if (!isTalkbackActive()) {
- when (state.button) {
- VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
- VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
- VoiceMessageState.Button.Downloading -> ProgressButton()
- VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
- VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
+ when (state.buttonType) {
+ VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
+ VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
+ VoiceMessageState.ButtonType.Downloading -> ProgressButton()
+ VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
+ VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
}
Spacer(Modifier.width(8.dp))
- Text(
- text = state.time,
- color = ElementTheme.colors.textSecondary,
- style = ElementTheme.typography.fontBodySmMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ PlaybackSpeedButton(
+ speed = state.playbackSpeed,
+ onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
+ )
+ Text(
+ text = state.time,
+ color = ElementTheme.colors.textSecondary,
+ style = ElementTheme.typography.fontBodySmMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
Spacer(Modifier.width(8.dp))
WaveformPlaybackView(
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = content.waveform,
- modifier = Modifier.height(34.dp),
+ modifier = Modifier
+ .weight(1f)
+ .height(34.dp),
seekEnabled = !isTalkbackActive(),
- onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
+ onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt
index 0c13214a7df..883c591fd29 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt
@@ -49,7 +49,7 @@ fun GroupHeaderView(
modifier: Modifier = Modifier
) {
// Ignore isHighlighted for now, we need a design decision on it.
- val backgroundColor = Color.Companion.Transparent
+ val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Box(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
index 8a19b88b263..3b0448ddb35 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt
@@ -112,7 +112,7 @@ fun EventDebugInfoView(
private fun prettyJSON(maybeJSON: String): String {
return try {
JSONObject(maybeJSON).toString(2)
- } catch (e: JSONException) {
+ } catch (_: JSONException) {
// Prefer not pretty-printing over crashing if the data is not actually JSON
maybeJSON
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index a3c8a1bb262..3d8467729a9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -9,7 +9,6 @@
package io.element.android.features.messages.impl.timeline.factories.event
import android.text.style.URLSpan
-import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans
import androidx.core.text.toSpannable
import dev.zacsweers.metro.Inject
@@ -35,11 +34,9 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
-import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
-import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
@@ -50,6 +47,7 @@ import io.element.android.libraries.matrix.ui.messages.toHtmlDocument
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
+import org.jsoup.nodes.Document
import kotlin.time.Duration
@Inject
@@ -60,7 +58,7 @@ class TimelineItemContentMessageFactory(
private val permalinkParser: PermalinkParser,
private val textPillificationHelper: TextPillificationHelper,
) {
- suspend fun create(
+ fun create(
content: MessageContent,
senderDisambiguatedDisplayName: String,
eventId: EventId?,
@@ -68,26 +66,29 @@ class TimelineItemContentMessageFactory(
return when (val messageType = content.type) {
is EmoteMessageType -> {
val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}"
- val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify(
- emoteBody
- ).safeLinkify()
+ val dom = messageType.formatted?.toHtmlDocument(
+ permalinkParser = permalinkParser,
+ prefix = "* $senderDisambiguatedDisplayName",
+ )
+ val formattedBody = dom?.let(::parseHtml)
+ ?: textPillificationHelper.pillify(emoteBody).safeLinkify()
TimelineItemEmoteContent(
body = emoteBody,
- htmlDocument = messageType.formatted?.toHtmlDocument(
- permalinkParser = permalinkParser,
- prefix = "* $senderDisambiguatedDisplayName",
- ),
+ htmlDocument = dom,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
}
is ImageMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -103,12 +104,15 @@ class TimelineItemContentMessageFactory(
)
}
is StickerMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemStickerContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
thumbnailSource = messageType.info?.thumbnailSource,
@@ -140,12 +144,15 @@ class TimelineItemContentMessageFactory(
}
}
is VideoMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemVideoContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -162,11 +169,14 @@ class TimelineItemContentMessageFactory(
)
}
is AudioMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
TimelineItemAudioContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -176,12 +186,15 @@ class TimelineItemContentMessageFactory(
)
}
is VoiceMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
TimelineItemVoiceContent(
eventId = eventId,
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
@@ -192,12 +205,15 @@ class TimelineItemContentMessageFactory(
)
}
is FileMessageType -> {
+ val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedCaption = dom?.let(::parseHtml)
+ ?: messageType.caption?.withLinks()
val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
TimelineItemFileContent(
filename = messageType.filename,
fileSize = messageType.info?.size ?: 0,
caption = messageType.caption?.trimEnd(),
- formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
+ formattedCaption = formattedCaption,
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
mediaSource = messageType.source,
@@ -208,9 +224,9 @@ class TimelineItemContentMessageFactory(
}
is NoticeMessageType -> {
val body = messageType.body.trimEnd()
- val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
- body
- ).safeLinkify()
+ val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedBody = dom?.let(::parseHtml)
+ ?: textPillificationHelper.pillify(body).safeLinkify()
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemNoticeContent(
body = body,
@@ -221,12 +237,13 @@ class TimelineItemContentMessageFactory(
}
is TextMessageType -> {
val body = messageType.body.trimEnd()
- val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify(
- body
- ).safeLinkify()
+ val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
+ val formattedBody = dom?.let(::parseHtml)
+ ?: textPillificationHelper.pillify(body).safeLinkify()
+ val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
TimelineItemTextContent(
body = body,
- htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
+ htmlDocument = htmlDocument,
formattedBody = formattedBody,
isEdited = content.isEdited,
)
@@ -253,21 +270,11 @@ class TimelineItemContentMessageFactory(
return result?.takeIf { it.isFinite() }
}
- private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
- if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
- val result = htmlConverterProvider.provide()
- .fromHtmlToSpans(formattedBody.body.trimEnd())
+ private fun parseHtml(document: Document): CharSequence? {
+ return htmlConverterProvider.provide()
+ .fromDocumentToSpans(document)
.let { textPillificationHelper.pillify(it) }
.safeLinkify()
- return if (prefix != null) {
- buildSpannedString {
- append(prefix)
- append(" ")
- append(result)
- }
- } else {
- result
- }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
index 051ded02ae6..56b0e402d29 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt
@@ -33,7 +33,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
@@ -111,7 +111,7 @@ class DefaultVoiceMessageComposerPresenter(
}
else -> {
Timber.i("Voice message permission needed")
- permissionState.eventSink(PermissionsEvents.RequestPermissions)
+ permissionState.eventSink(PermissionsEvent.RequestPermissions)
}
}
}
@@ -176,10 +176,10 @@ class DefaultVoiceMessageComposerPresenter(
localCoroutineScope.deleteRecording()
}
VoiceMessageComposerEvent.DismissPermissionsRationale -> {
- permissionState.eventSink(PermissionsEvents.CloseDialog)
+ permissionState.eventSink(PermissionsEvent.CloseDialog)
}
VoiceMessageComposerEvent.AcceptPermissionRationale -> {
- permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
+ permissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog)
}
is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event)
VoiceMessageComposerEvent.DismissSendFailureDialog -> {
diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..024a9ac1c7b
--- /dev/null
+++ b/features/messages/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,84 @@
+
+
+ "Aktivnosti"
+ "Zastave"
+ "Hrana i piće"
+ "Životinje i priroda"
+ "Objekti"
+ "Emotikoni i osobe"
+ "Putovanja i mjesta"
+ "Nedavni emotikoni"
+ "Simboli"
+ "Opisi možda neće biti vidljivi osobama koji se služe starijim aplikacijama."
+ "Dodirnite za promjenu kvalitete prijenosa videozapisa"
+ "Datoteka se nije mogla prenijeti."
+ "Prijenos medija za obradu nije uspio, pokušajte ponovno."
+ "Prijenos medija nije uspio, pokušajte ponovno."
+ "Maksimalna dopuštena veličina datoteke je %1$s."
+ "Datoteka je prevelika za prijenos"
+ "Stavka %1$d od %2$d"
+ "Optimiziraj kvalitetu slike"
+ "Obrada…"
+ "Blokiraj korisnika"
+ "Označite ako želite sakriti sve trenutačne i buduće poruke od ovog korisnika"
+ "Ova poruka bit će prijavljena administratoru vašeg matičnog poslužitelja. On neće moći pročitati nijednu šifriranu poruku."
+ "Razlog za prijavu ovog sadržaja"
+ "Kamera"
+ "Uslikaj"
+ "Snimi videozapis"
+ "Privitak"
+ "Biblioteka fotografija i videozapisa"
+ "Lokacija"
+ "Anketa"
+ "Oblikovanje teksta"
+ "Povijest poruka trenutačno nije dostupna."
+ "Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka."
+ "Želite li ih pozvati natrag?"
+ "Sami ste u ovom razgovoru"
+ "Obavijestite cijelu sobu"
+ "Svi"
+ "Pošalji ponovno"
+ "Slanje vaše poruke nije uspjelo"
+ "Dodaj reakciju"
+ "Ovo je početak sobe %1$s."
+ "Ovo je početak ovog razgovora."
+ "Nepodržani poziv. Pitajte pozivatelja može li se služiti novom aplikacijom %1$s."
+ "Prikaži manje"
+ "Poruka je kopirana"
+ "Nemate dopuštenje za objavljivanje u ovoj sobi"
+
+ - "%1$d član reagirao je s %2$s"
+ - "%1$d člana reagirala su s %2$s"
+ - "%1$d članova reagiralo je s %2$s"
+
+
+ - "Vi i %1$d član reagirali ste s %2$s"
+ - "Vi i %1$d člana reagirali ste s %2$s"
+ - "Vi i %1$d članova reagirali ste s %2$s"
+
+ "Reagirali ste s %1$s"
+ "Prikaži manje"
+ "Prikaži više"
+ "Prikaži sažetak reakcija"
+ "Novo"
+
+ - "%1$d promjena sobe"
+ - "%1$d promjene sobe"
+ - "%1$d promjena sobe"
+
+ "Prijeđi u novu sobu"
+ "Ova je soba zamijenjena i više nije aktivna"
+ "Pogledaj stare poruke"
+ "Ova je soba nastavak druge sobe"
+
+ - "%1$s, %2$s i ostalih %3$d"
+ - "%1$s, %2$s i ostalih %3$d"
+ - "%1$s, %2$s i ostalih %3$d"
+
+
+ - "%1$s tipka"
+ - "%1$s tipka"
+ - "%1$s tipka"
+
+ "%1$s i %2$s"
+
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
index 852e2504b20..bdf84e9eec4 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.anActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
+import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState
import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.link.aLinkState
@@ -63,6 +64,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -85,6 +87,7 @@ 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
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
@@ -142,11 +145,7 @@ class MessagesPresenterTest {
fun `present - check that the room's unread flag is removed`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
markAsReadResult = { lambdaError() }
),
typingNoticeResult = { Result.success(Unit) },
@@ -172,11 +171,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -222,11 +217,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -287,11 +278,7 @@ class MessagesPresenterTest {
val event = aMessageEvent()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
eventPermalinkResult = { Result.success("a link") },
),
typingNoticeResult = { Result.success(Unit) },
@@ -513,11 +500,7 @@ class MessagesPresenterTest {
val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = liveTimeline,
typingNoticeResult = { Result.success(Unit) },
@@ -585,11 +568,7 @@ class MessagesPresenterTest {
fun `present - shows prompt to reinvite users in DM`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1))
},
@@ -618,11 +597,7 @@ class MessagesPresenterTest {
fun `present - doesn't show reinvite prompt in non-direct room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = false, joinedMembersCount = 1, activeMembersCount = 1))
},
@@ -644,11 +619,7 @@ class MessagesPresenterTest {
fun `present - doesn't show reinvite prompt if other party is present`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2))
},
@@ -671,11 +642,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult,
@@ -706,11 +673,7 @@ class MessagesPresenterTest {
val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = inviteUserResult,
@@ -743,11 +706,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when memberlist is not ready`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -768,11 +727,7 @@ class MessagesPresenterTest {
fun `present - handle reinviting other user when inviting fails`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
inviteUserResult = { Result.failure(RuntimeException("Oops!")) },
@@ -806,17 +761,7 @@ class MessagesPresenterTest {
fun `present - permission to post`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
- canUserSendMessageResult = { _, messageEventType ->
- when (messageEventType) {
- MessageEventType.RoomMessage -> Result.success(true)
- MessageEventType.Reaction -> Result.success(true)
- else -> lambdaError()
- }
- },
+ roomPermissions = roomPermissions(),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -832,17 +777,9 @@ class MessagesPresenterTest {
fun `present - no permission to post`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
- canUserSendMessageResult = { _, messageEventType ->
- when (messageEventType) {
- MessageEventType.RoomMessage -> Result.success(false)
- MessageEventType.Reaction -> Result.success(false)
- else -> lambdaError()
- }
- },
+ roomPermissions = roomPermissions(
+ canSendMessage = false
+ ),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -858,11 +795,9 @@ class MessagesPresenterTest {
fun `present - permission to redact own`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOtherResult = { Result.success(false) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(
+ canRedactOther = false
+ ),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -879,11 +814,9 @@ class MessagesPresenterTest {
fun `present - permission to redact other`() = runTest {
val joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOtherResult = { Result.success(true) },
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(false) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(
+ canRedactOwn = false
+ ),
),
typingNoticeResult = { Result.success(Unit) },
)
@@ -928,11 +861,7 @@ class MessagesPresenterTest {
val timeline = FakeTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -972,11 +901,7 @@ class MessagesPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -1073,11 +998,7 @@ class MessagesPresenterTest {
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
),
liveTimeline = timeline,
typingNoticeResult = { Result.success(Unit) },
@@ -1114,11 +1035,7 @@ class MessagesPresenterTest {
val successorReason = "This room has been moved to a new location"
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
successorRoom = SuccessorRoom(
roomId = successorRoomId,
@@ -1142,11 +1059,7 @@ class MessagesPresenterTest {
fun `present - room without successor room has null successor info in state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(successorRoom = null)
),
typingNoticeResult = { Result.success(Unit) },
@@ -1164,11 +1077,13 @@ class MessagesPresenterTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
sessionId = A_SESSION_ID,
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = FakeRoomPermissions(
+ canSendState = { true },
+ canSendMessage = { true },
+ canRedactOther = true,
+ canRedactOwn = true,
+ canPinUnpin = true,
+ ),
initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true)
).apply {
givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2))))
@@ -1311,16 +1226,44 @@ class MessagesPresenterTest {
}
}
+ private fun roomPermissions(
+ canStartCall: Boolean = true,
+ canRedactOther: Boolean = true,
+ canRedactOwn: Boolean = true,
+ canSendMessage: Boolean = true,
+ canSendReaction: Boolean = true,
+ canPinUnpin: Boolean = true,
+ ) = FakeRoomPermissions(
+ canSendState = { type ->
+ when (type) {
+ StateEventType.CallMember -> canStartCall
+ else -> lambdaError()
+ }
+ },
+ canSendMessage = { type ->
+ when (type) {
+ MessageEventType.RoomMessage -> canSendMessage
+ MessageEventType.Reaction -> canSendReaction
+ else -> lambdaError()
+ }
+ },
+ canRedactOther = canRedactOther,
+ canRedactOwn = canRedactOwn,
+ canPinUnpin = canPinUnpin,
+ )
+
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
timeline: Timeline = FakeTimeline(),
joinedRoom: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserJoinCallResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = FakeRoomPermissions(
+ canSendState = { true },
+ canSendMessage = { true },
+ canRedactOther = true,
+ canRedactOwn = true,
+ canPinUnpin = true,
+ ),
).apply {
givenRoomInfo(aRoomInfo(id = roomId, name = ""))
},
@@ -1355,6 +1298,7 @@ class MessagesPresenterTest {
timelinePresenter = { aTimelineState(eventSink = timelineEventSink) },
timelineProtectionPresenter = { aTimelineProtectionState() },
identityChangeStatePresenter = { anIdentityChangeState() },
+ historyVisibleStatePresenter = { aHistoryVisibleState() },
linkPresenter = { aLinkState() },
actionListPresenter = { anActionListState(eventSink = actionListEventSink) },
customReactionPresenter = { aCustomReactionState() },
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
index 5263182fa0a..fe462fc9881 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt
@@ -44,6 +44,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSenderFactory
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
+import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
@@ -598,6 +599,7 @@ class AttachmentsPreviewPresenterTest {
)
}
),
+ mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
@@ -619,6 +621,7 @@ class AttachmentsPreviewPresenterTest {
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
timelineMode = timelineMode,
inReplyToEventId = null,
+ mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt
index aad3e0b8850..96cc93ea3dd 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt
@@ -22,12 +22,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider
+import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
-import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@@ -233,16 +233,16 @@ class DefaultMediaOptimizationSelectorPresenterTest {
private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
- sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
+ mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
): DefaultMediaOptimizationSelectorPresenter {
return DefaultMediaOptimizationSelectorPresenter(
localMedia = localMedia,
maxUploadSizeProvider = maxUploadSizeProvider,
- sessionPreferencesStore = sessionPreferencesStore,
featureFlagService = featureFlagService,
mediaExtractorFactory = mediaExtractorFactory,
+ mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt
new file mode 100644
index 00000000000..faf21720faa
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FakeHistoryVisibleAcknowledgementRepository(
+ private val acknowledgements: MutableMap> = mutableMapOf()
+) : HistoryVisibleAcknowledgementRepository {
+ override fun hasAcknowledged(roomId: RoomId): Flow {
+ return acknowledgements.getOrPut(roomId) {
+ MutableStateFlow(false)
+ }
+ }
+
+ override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) {
+ val flow = acknowledgements.getOrPut(roomId) {
+ MutableStateFlow(value)
+ }
+ flow.emit(value)
+ }
+
+ companion object {
+ /**
+ * Create the repository with a pre-existing entry.
+ */
+ fun withRoom(roomId: RoomId, acknowledged: Boolean = false): FakeHistoryVisibleAcknowledgementRepository {
+ return FakeHistoryVisibleAcknowledgementRepository(
+ mutableMapOf(
+ roomId to MutableStateFlow(acknowledged)
+ )
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt
new file mode 100644
index 00000000000..afa1992cacc
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.impl.crypto.historyvisible
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class HistoryVisibleStatePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - not visible if feature disabled`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room, enabled = false, acknowledged = false)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room shared, unencrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = false))
+ val presenter = createHistoryVisibleStatePresenter(room)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room joined, encrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room invited, encrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Invited, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - initial with room shared, encrypted, unacknowledged`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room, acknowledged = false)
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.showAlert).isFalse()
+ val nextState = awaitItem()
+ assertThat(nextState.showAlert).isTrue()
+ }
+ }
+
+ @Test
+ fun `present - initial with room shared, encrypted, acknowledged`() = runTest {
+ val room = FakeJoinedRoom()
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ val presenter = createHistoryVisibleStatePresenter(room, acknowledged = true)
+ presenter.test {
+ assertThat(awaitLastSequentialItem().showAlert).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - transition from joined + unencrypted, to shared + encrypted`() = runTest {
+ val room = FakeJoinedRoom()
+ val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true))
+ val repository = FakeHistoryVisibleAcknowledgementRepository()
+
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false))
+
+ val presenter = HistoryVisibleStatePresenter(
+ featureFlagService,
+ repository,
+ room,
+ )
+
+ presenter.test {
+ // emitted by the feature flag service(?)
+ assertThat(awaitItem().showAlert).isFalse()
+
+ // emitted state from room info assignment
+ assertThat(awaitItem().showAlert).isFalse()
+
+ // room is marked as encrypted
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true))
+ assertThat(awaitItem().showAlert).isFalse()
+
+ // room history visibility is changed to shared
+ room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true))
+ assertThat(awaitItem().showAlert).isTrue()
+
+ // alert is acknowledged
+ repository.setAcknowledged(room.roomId, true)
+ assertThat(awaitItem().showAlert).isFalse()
+ }
+ }
+
+ private fun createHistoryVisibleStatePresenter(
+ room: JoinedRoom = FakeJoinedRoom(),
+ enabled: Boolean = true,
+ acknowledged: Boolean = false
+ ): HistoryVisibleStatePresenter {
+ return HistoryVisibleStatePresenter(
+ room = room,
+ featureFlagService = FakeFeatureFlagService(mapOf("feature.enableKeyShareOnInvite" to enabled)),
+ repository = FakeHistoryVisibleAcknowledgementRepository.withRoom(room.roomId, acknowledged)
+ )
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
index 4a6e7771163..2feafbc9e55 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt
@@ -69,6 +69,7 @@ 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
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.mediapickers.api.PickerProvider
@@ -991,9 +992,12 @@ class MessageComposerPresenterTest {
val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
- var canUserTriggerRoomNotificationResult = true
val room = FakeJoinedRoom(
- baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = FakeRoomPermissions(
+ canTriggerRoomNotification = true,
+ )
+ ),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
@@ -1033,10 +1037,38 @@ class MessageComposerPresenterTest {
// If the suggestion isn't a mention, no suggestions are returned
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
assertThat(awaitItem().suggestions).isEmpty()
+ }
+ }
- // If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
- canUserTriggerRoomNotificationResult = false
+ @Test
+ fun `present - room mention suggestions no permission`() = runTest {
+ val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
+ val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
+ val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
+ val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
+ val room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = FakeRoomPermissions(
+ canTriggerRoomNotification = false,
+ )
+ ),
+ typingNoticeResult = { Result.success(Unit) }
+ ).apply {
+ givenRoomMembersState(
+ RoomMembersState.Ready(
+ persistentListOf(currentUser, invitedUser, bob, david),
+ )
+ )
+ givenRoomInfo(aRoomInfo(isDirect = false))
+ }
+ val presenter = createPresenter(room)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ // An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
+ skipItems(1)
assertThat(awaitItem().suggestions)
.containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david))
}
@@ -1049,7 +1081,9 @@ class MessageComposerPresenterTest {
val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
val room = FakeJoinedRoom(
- baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(true) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true),
+ ),
typingNoticeResult = { Result.success(Unit) }
).apply {
givenRoomMembersState(
@@ -1069,7 +1103,6 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
-
// An empty suggestion returns the joined members that are not the current user, but not the room
initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
skipItems(1)
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
index 8807951d9de..351f841fcf5 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID
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
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
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
@@ -55,9 +56,7 @@ class PinnedMessagesListPresenterTest {
fun `present - initial state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
}
@@ -74,9 +73,7 @@ class PinnedMessagesListPresenterTest {
fun `present - timeline failure state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -95,9 +92,7 @@ class PinnedMessagesListPresenterTest {
fun `present - empty state`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf()))
},
@@ -117,9 +112,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -146,9 +139,7 @@ class PinnedMessagesListPresenterTest {
val analyticsService = FakeAnalyticsService()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -194,9 +185,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -225,9 +214,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -256,9 +243,7 @@ class PinnedMessagesListPresenterTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline()
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canRedactOwnResult = { Result.success(true) },
- canRedactOtherResult = { Result.success(true) },
- canUserPinUnpinResult = { Result.success(true) },
+ roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
@@ -295,6 +280,16 @@ class PinnedMessagesListPresenterTest {
)
}
+ private fun roomPermissions(
+ canRedactOther: Boolean = true,
+ canRedactOwn: Boolean = true,
+ canPinUnpin: Boolean = true,
+ ) = FakeRoomPermissions(
+ canRedactOther = canRedactOther,
+ canRedactOwn = canRedactOwn,
+ canPinUnpin = canPinUnpin,
+ )
+
private fun TestScope.createPinnedMessagesListPresenter(
navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(),
room: JoinedRoom = FakeJoinedRoom(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
index f30c41b53b5..10d92033808 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt
@@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
@@ -36,6 +34,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.core.asEventId
+import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -57,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
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.aRoomMember
+import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
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
@@ -68,6 +68,7 @@ import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.any
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
import io.element.android.tests.testutils.test
@@ -125,9 +126,7 @@ class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createTimelinePresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineItems).isEmpty()
assertThat(initialState.isLive).isTrue()
@@ -146,9 +145,7 @@ class TimelinePresenterTest {
this.paginateLambda = paginateLambda
}
val presenter = createTimelinePresenter(timeline = timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS))
@@ -194,9 +191,6 @@ class TimelinePresenterTest {
)
val room = FakeJoinedRoom(
liveTimeline = timeline,
- baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
- )
)
val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled)
val presenter = createTimelinePresenter(
@@ -204,9 +198,7 @@ class TimelinePresenterTest {
room = room,
sessionPreferencesStore = sessionPreferencesStore,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
runCurrent()
@@ -239,9 +231,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@@ -280,9 +270,7 @@ class TimelinePresenterTest {
timeline = timeline,
sessionPreferencesStore = sessionPreferencesStore,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(0))
@@ -318,9 +306,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().run {
eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@@ -348,9 +334,7 @@ class TimelinePresenterTest {
this.sendReadReceiptLambda = sendReadReceiptsLambda
}
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
@@ -367,9 +351,7 @@ class TimelinePresenterTest {
markAsReadResult = { Result.success(Unit) },
)
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
@@ -418,9 +400,7 @@ class TimelinePresenterTest {
timelineItems = timelineItems,
)
val presenter = createTimelinePresenter(timeline)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.newEventState).isEqualTo(NewEventState.None)
assertThat(initialState.timelineItems.size).isEqualTo(0)
@@ -474,9 +454,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
sendPollResponseAction = sendPollResponseAction,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId"))
}
@@ -490,9 +468,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
endPollAction = endPollAction,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID))
}
@@ -509,9 +485,7 @@ class TimelinePresenterTest {
val presenter = createTimelinePresenter(
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID))
onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
}
@@ -528,9 +502,7 @@ class TimelinePresenterTest {
),
redactedVoiceMessageManager = redactedVoiceMessageManager,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0)
skipItems(2)
assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1)
@@ -556,16 +528,14 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
val presenter = createTimelinePresenter(
room = room,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
@@ -607,15 +577,13 @@ class TimelinePresenterTest {
)
),
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { Result.success(null) },
),
),
timelineItemIndexer = timelineItemIndexer,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
advanceUntilIdle()
@@ -647,14 +615,12 @@ class TimelinePresenterTest {
),
createTimelineResult = { Result.failure(RuntimeException("An error")) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(null) },
),
)
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
awaitItem().also { state ->
@@ -696,7 +662,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
@@ -707,9 +673,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -757,7 +721,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
threadRootIdForEventResult = { _ -> Result.success(threadId) },
),
)
@@ -768,9 +732,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -813,7 +775,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
// Use a different thread id
threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) },
),
@@ -825,9 +787,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -874,7 +834,7 @@ class TimelinePresenterTest {
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(detachedTimeline) },
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
// The event is in the main timeline, not in a thread
threadRootIdForEventResult = { _ -> Result.success(null) },
),
@@ -886,9 +846,7 @@ class TimelinePresenterTest {
timeline = liveTimeline,
messagesNavigator = navigator,
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID))
@@ -919,9 +877,7 @@ class TimelinePresenterTest {
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
val shield = aCriticalShield()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.messageShield).isNull()
initialState.eventSink(TimelineEvents.ShowShieldDialog(shield))
@@ -957,7 +913,9 @@ class TimelinePresenterTest {
)
val room = FakeJoinedRoom(
liveTimeline = timeline,
- baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = roomPermissions(),
+ ),
).apply {
givenRoomMembersState(RoomMembersState.Unknown)
}
@@ -965,9 +923,7 @@ class TimelinePresenterTest {
val avatarUrl = "https://domain.com/avatar.jpg"
val presenter = createTimelinePresenter(timeline, room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last()
val event = initialState.timelineItems.first() as TimelineItem.Event
assertThat(event.senderAvatar.url).isNull()
@@ -991,15 +947,13 @@ class TimelinePresenterTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
predecessorRoomResult = { predecessorRoom }
),
)
val presenter = createTimelinePresenter(room = room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull()
assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId)
@@ -1010,14 +964,12 @@ class TimelinePresenterTest {
fun `present - timeline room info no predecessor`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
predecessorRoomResult = { null }
),
)
val presenter = createTimelinePresenter(room = room)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull()
}
@@ -1027,7 +979,7 @@ class TimelinePresenterTest {
fun `present - timeline event navigate to room`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
- canUserSendMessageResult = { _, _ -> Result.success(true) },
+ roomPermissions = roomPermissions(),
),
)
val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _, _ -> }
@@ -1053,11 +1005,32 @@ class TimelinePresenterTest {
return awaitItem()
}
+ private fun roomPermissions(
+ canRedactOther: Boolean = false,
+ canRedactOwn: Boolean = true,
+ canSendMessage: Boolean = true,
+ canSendReaction: Boolean = true,
+ canPinUnpin: Boolean = false,
+ ) = FakeRoomPermissions(
+ canSendMessage = { type ->
+ when (type) {
+ MessageEventType.RoomMessage -> canSendMessage
+ MessageEventType.Reaction -> canSendReaction
+ else -> lambdaError()
+ }
+ },
+ canRedactOther = canRedactOther,
+ canRedactOwn = canRedactOwn,
+ canPinUnpin = canPinUnpin,
+ )
+
private fun TestScope.createTimelinePresenter(
timeline: Timeline = FakeTimeline(),
room: FakeJoinedRoom = FakeJoinedRoom(
liveTimeline = timeline,
- baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }),
+ baseRoom = FakeBaseRoom(
+ roomPermissions = roomPermissions(),
+ ),
),
redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(),
messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
index 160e3689162..9f23388ecb9 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
@@ -67,6 +67,7 @@ import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
+import org.jsoup.nodes.Document
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@@ -187,7 +188,7 @@ class TimelineItemContentMessageFactoryTest {
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
- htmlConverterTransform = { expected }
+ domConverterTransform = { expected }
)
val result = sut.create(
content = createMessageContent(
@@ -679,7 +680,7 @@ class TimelineItemContentMessageFactoryTest {
}
}.toSpannable()
val sut = createTimelineItemContentMessageFactory(
- htmlConverterTransform = { expectedSpanned },
+ domConverterTransform = { expectedSpanned },
permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) }
)
val result = sut.create(
@@ -765,11 +766,12 @@ class TimelineItemContentMessageFactoryTest {
private fun createTimelineItemContentMessageFactory(
htmlConverterTransform: (String) -> CharSequence = { it },
+ domConverterTransform: (Document) -> CharSequence = { it.body().html() },
permalinkParser: FakePermalinkParser = FakePermalinkParser(),
) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
- htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
+ htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform),
permalinkParser = permalinkParser,
textPillificationHelper = FakeTextPillificationHelper(),
)
diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt
new file mode 100644
index 00000000000..cb90db29afc
--- /dev/null
+++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.messages.test.pinned
+
+import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider
+import kotlinx.coroutines.flow.StateFlow
+
+class FakePinnedEventsTimelineProvider(
+ private val fakeTimelineProvider: FakeTimelineProvider = FakeTimelineProvider(),
+) : PinnedEventsTimelineProvider {
+ override fun activeTimelineFlow(): StateFlow = fakeTimelineProvider.activeTimelineFlow()
+}
diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
index 75b700b58de..1277783f6a8 100644
--- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
+++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
@@ -11,9 +11,11 @@ package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.wysiwyg.utils.HtmlConverter
+import org.jsoup.nodes.Document
class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it },
+ private val transformDom: (Document) -> CharSequence = { it.html() },
) : HtmlConverterProvider {
@Composable
override fun Update() = Unit
@@ -23,6 +25,10 @@ class FakeHtmlConverterProvider(
override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html)
}
+
+ override fun fromDocumentToSpans(dom: Document): CharSequence {
+ return transformDom(dom)
+ }
}
}
}
diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt
new file mode 100644
index 00000000000..a420ac8e8f7
--- /dev/null
+++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.migration.impl.migrations
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesIntoSet
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.sessionstorage.api.SessionStore
+
+/**
+ * Ensure we clear the well-known cached config, since it could be invalid due to an SDK issue.
+ */
+@ContributesIntoSet(AppScope::class)
+@Inject
+class AppMigration09(
+ private val sessionStore: SessionStore,
+ private val matrixClientProvider: MatrixClientProvider,
+) : AppMigration {
+ override val order: Int = 9
+
+ override suspend fun migrate(isFreshInstall: Boolean) {
+ if (isFreshInstall) return
+
+ val sessions = sessionStore.getAllSessions()
+
+ for (session in sessions) {
+ val client = matrixClientProvider.getOrRestore(SessionId(session.userId)).getOrNull() ?: continue
+ client.resetWellKnownConfig()
+ }
+ }
+}
diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt
new file mode 100644
index 00000000000..380ea974647
--- /dev/null
+++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.migration.impl.migrations
+
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
+import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
+import io.element.android.libraries.sessionstorage.test.aSessionData
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class AppMigration09Test {
+ @Test
+ fun `migration on fresh install does nothing`() = runTest {
+ val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData()))
+ val getClientLambda = lambdaRecorder> { Result.success(FakeMatrixClient()) }
+ val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda)
+ val migration = AppMigration09(sessionStore, clientProvider)
+ migration.migrate(isFreshInstall = true)
+
+ getClientLambda.assertions().isNeverCalled()
+ }
+
+ @Test
+ fun `migration on upgrade should invoke the resetWellKnownConfig method`() = runTest {
+ val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData()))
+ val resetWellKnownLambda = lambdaRecorder> { Result.success(Unit) }
+ val getClientLambda = lambdaRecorder> {
+ Result.success(FakeMatrixClient(resetWellKnownConfigLambda = resetWellKnownLambda))
+ }
+ val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda)
+ val migration = AppMigration09(sessionStore, clientProvider)
+ migration.migrate(isFreshInstall = false)
+
+ getClientLambda.assertions().isCalledOnce()
+ resetWellKnownLambda.assertions().isCalledOnce()
+ }
+}
diff --git a/features/poll/api/src/main/res/values-hr/translations.xml b/features/poll/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..117ed51c6b3
--- /dev/null
+++ b/features/poll/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,10 @@
+
+
+
+ - "%1$d posto ukupnog broja glasova"
+ - "%1$d posto ukupnog broja glasova"
+ - "%1$d posto ukupnog broja glasova"
+
+ "Uklonit će prethodni odabir"
+ "Ovo je pobjednički odgovor"
+
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt
similarity index 56%
rename from features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt
rename to features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt
index 3d1c162dd3a..b98ab899b97 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt
@@ -10,15 +10,15 @@ package io.element.android.features.poll.impl.create
import io.element.android.libraries.matrix.api.poll.PollKind
-sealed interface CreatePollEvents {
- data object Save : CreatePollEvents
- data class Delete(val confirmed: Boolean) : CreatePollEvents
- data class SetQuestion(val question: String) : CreatePollEvents
- data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
- data object AddAnswer : CreatePollEvents
- data class RemoveAnswer(val index: Int) : CreatePollEvents
- data class SetPollKind(val pollKind: PollKind) : CreatePollEvents
- data object NavBack : CreatePollEvents
- data object ConfirmNavBack : CreatePollEvents
- data object HideConfirmation : CreatePollEvents
+sealed interface CreatePollEvent {
+ data object Save : CreatePollEvent
+ data class Delete(val confirmed: Boolean) : CreatePollEvent
+ data class SetQuestion(val question: String) : CreatePollEvent
+ data class SetAnswer(val index: Int, val text: String) : CreatePollEvent
+ data object AddAnswer : CreatePollEvent
+ data class RemoveAnswer(val index: Int) : CreatePollEvent
+ data class SetPollKind(val pollKind: PollKind) : CreatePollEvent
+ data object NavBack : CreatePollEvent
+ data object ConfirmNavBack : CreatePollEvent
+ data object HideConfirmation : CreatePollEvent
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
index 3da8c3dc538..6138bab2aea 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt
@@ -97,9 +97,9 @@ class CreatePollPresenter(
val scope = rememberCoroutineScope()
- fun handleEvent(event: CreatePollEvents) {
+ fun handleEvent(event: CreatePollEvent) {
when (event) {
- is CreatePollEvents.Save -> scope.launch {
+ is CreatePollEvent.Save -> scope.launch {
if (canSave) {
repository.savePoll(
existingPollId = when (mode) {
@@ -123,7 +123,7 @@ class CreatePollPresenter(
Timber.d("Cannot create poll")
}
}
- is CreatePollEvents.Delete -> {
+ is CreatePollEvent.Delete -> {
if (mode !is CreatePollMode.EditPoll) {
return
}
@@ -139,25 +139,25 @@ class CreatePollPresenter(
navigateUp()
}
}
- is CreatePollEvents.AddAnswer -> {
+ is CreatePollEvent.AddAnswer -> {
poll = poll.withNewAnswer()
}
- is CreatePollEvents.RemoveAnswer -> {
+ is CreatePollEvent.RemoveAnswer -> {
poll = poll.withAnswerRemoved(event.index)
}
- is CreatePollEvents.SetAnswer -> {
+ is CreatePollEvent.SetAnswer -> {
poll = poll.withAnswerChanged(event.index, event.text)
}
- is CreatePollEvents.SetPollKind -> {
+ is CreatePollEvent.SetPollKind -> {
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
}
- is CreatePollEvents.SetQuestion -> {
+ is CreatePollEvent.SetQuestion -> {
poll = poll.copy(question = event.question)
}
- is CreatePollEvents.NavBack -> {
+ is CreatePollEvent.NavBack -> {
navigateUp()
}
- CreatePollEvents.ConfirmNavBack -> {
+ CreatePollEvent.ConfirmNavBack -> {
val shouldConfirm = isDirty
if (shouldConfirm) {
showBackConfirmation = true
@@ -165,7 +165,7 @@ class CreatePollPresenter(
navigateUp()
}
}
- is CreatePollEvents.HideConfirmation -> {
+ is CreatePollEvent.HideConfirmation -> {
showBackConfirmation = false
showDeleteConfirmation = false
}
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt
index 1046f25bd59..80aa7dc4b05 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt
@@ -20,7 +20,7 @@ data class CreatePollState(
val pollKind: PollKind,
val showBackConfirmation: Boolean,
val showDeleteConfirmation: Boolean,
- val eventSink: (CreatePollEvents) -> Unit,
+ val eventSink: (CreatePollEvent) -> Unit,
) {
enum class Mode {
New,
diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
index 53caf33707d..3abf3718af3 100644
--- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
+++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt
@@ -62,20 +62,21 @@ fun CreatePollView(
) {
val coroutineScope = rememberCoroutineScope()
- val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
+ val navBack = { state.eventSink(CreatePollEvent.ConfirmNavBack) }
BackHandler(onBack = navBack)
if (state.showBackConfirmation) {
SaveChangesDialog(
- onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) },
- onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
+ onSaveClick = { state.eventSink(CreatePollEvent.Save) },
+ onDiscardClick = { state.eventSink(CreatePollEvent.NavBack) },
+ onDismiss = { state.eventSink(CreatePollEvent.HideConfirmation) },
)
}
if (state.showDeleteConfirmation) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title),
content = stringResource(id = R.string.screen_edit_poll_delete_confirmation),
- onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) },
- onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
+ onSubmitClick = { state.eventSink(CreatePollEvent.Delete(confirmed = true)) },
+ onDismiss = { state.eventSink(CreatePollEvent.HideConfirmation) }
)
}
val questionFocusRequester = remember { FocusRequester() }
@@ -90,7 +91,7 @@ fun CreatePollView(
mode = state.mode,
saveEnabled = state.canSave,
onBackClick = navBack,
- onSaveClick = { state.eventSink(CreatePollEvents.Save) }
+ onSaveClick = { state.eventSink(CreatePollEvent.Save) }
)
},
) { paddingValues ->
@@ -111,7 +112,7 @@ fun CreatePollView(
label = stringResource(id = R.string.screen_create_poll_question_desc),
value = state.question,
onValueChange = {
- state.eventSink(CreatePollEvents.SetQuestion(it))
+ state.eventSink(CreatePollEvent.SetQuestion(it))
},
modifier = Modifier
.focusRequester(questionFocusRequester)
@@ -130,7 +131,7 @@ fun CreatePollView(
TextField(
value = answer.text,
onValueChange = {
- state.eventSink(CreatePollEvents.SetAnswer(index, it))
+ state.eventSink(CreatePollEvent.SetAnswer(index, it))
},
modifier = Modifier
.then(if (isLastItem) Modifier.focusRequester(answerFocusRequester) else Modifier)
@@ -144,7 +145,7 @@ fun CreatePollView(
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text),
modifier = Modifier.clickable(answer.canDelete) {
- state.eventSink(CreatePollEvents.RemoveAnswer(index))
+ state.eventSink(CreatePollEvent.RemoveAnswer(index))
},
)
},
@@ -160,7 +161,7 @@ fun CreatePollView(
),
style = ListItemStyle.Primary,
onClick = {
- state.eventSink(CreatePollEvents.AddAnswer)
+ state.eventSink(CreatePollEvent.AddAnswer)
coroutineScope.launch(Dispatchers.Main) {
lazyListState.animateScrollToItem(state.answers.size + 1)
answerFocusRequester.requestFocus()
@@ -180,7 +181,7 @@ fun CreatePollView(
),
onClick = {
state.eventSink(
- CreatePollEvents.SetPollKind(
+ CreatePollEvent.SetPollKind(
if (state.pollKind == PollKind.Disclosed) PollKind.Undisclosed else PollKind.Disclosed
)
)
@@ -190,7 +191,7 @@ fun CreatePollView(
ListItem(
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
style = ListItemStyle.Destructive,
- onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) },
+ onClick = { state.eventSink(CreatePollEvent.Delete(confirmed = false)) },
)
}
}
diff --git a/features/poll/impl/src/main/res/values-hr/translations.xml b/features/poll/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..adc11a1be79
--- /dev/null
+++ b/features/poll/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,20 @@
+
+
+ "Dodaj mogućnost"
+ "Prikaži rezultate tek nakon završetka ankete"
+ "Sakrij glasove"
+ "Mogućnost %1$d"
+ "Vaše promjene nisu spremljene. Jeste li sigurni da se želite vratiti?"
+ "Izbriši mogućnost %1$s"
+ "Pitanje ili tema"
+ "O čemu se radi u anketi?"
+ "Izradi anketu"
+ "Jeste li sigurni da želite izbrisati ovu anketu?"
+ "Izbriši anketu"
+ "Uredi anketu"
+ "Ne mogu pronaći nijednu tekuću anketu."
+ "Ne mogu pronaći nijednu prijašnju anketu."
+ "Tekuće"
+ "Prijašnje"
+ "Ankete"
+
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
index 1f916eb670c..dee0268c1c7 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt
@@ -104,15 +104,15 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(initial.canSave).isFalse()
- initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
+ initial.eventSink(CreatePollEvent.SetQuestion("A question?"))
val questionSet = awaitItem()
assertThat(questionSet.canSave).isFalse()
- questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
+ questionSet.eventSink(CreatePollEvent.SetAnswer(0, "Answer 1"))
val answer1Set = awaitItem()
assertThat(answer1Set.canSave).isFalse()
- answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
+ answer1Set.eventSink(CreatePollEvent.SetAnswer(1, "Answer 2"))
val answer2Set = awaitItem()
assertThat(answer2Set.canSave).isTrue()
}
@@ -133,11 +133,11 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
- initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
- initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
+ initial.eventSink(CreatePollEvent.SetQuestion("A question?"))
+ initial.eventSink(CreatePollEvent.SetAnswer(0, "Answer 1"))
+ initial.eventSink(CreatePollEvent.SetAnswer(1, "Answer 2"))
skipItems(3)
- initial.eventSink(CreatePollEvents.Save)
+ initial.eventSink(CreatePollEvent.Save)
delay(1) // Wait for the coroutine to finish
createPollResult.assertions().isCalledOnce()
.with(
@@ -182,10 +182,10 @@ class CreatePollPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
- awaitDefaultItem().eventSink(CreatePollEvents.SetQuestion("A question?"))
- awaitItem().eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
- awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
- awaitItem().eventSink(CreatePollEvents.Save)
+ awaitDefaultItem().eventSink(CreatePollEvent.SetQuestion("A question?"))
+ awaitItem().eventSink(CreatePollEvent.SetAnswer(0, "Answer 1"))
+ awaitItem().eventSink(CreatePollEvent.SetAnswer(1, "Answer 2"))
+ awaitItem().eventSink(CreatePollEvent.Save)
delay(1) // Wait for the coroutine to finish
createPollResult.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
@@ -210,20 +210,20 @@ class CreatePollPresenterTest {
}.test {
awaitDefaultItem()
awaitPollLoaded().apply {
- eventSink(CreatePollEvents.SetQuestion("Changed question"))
+ eventSink(CreatePollEvent.SetQuestion("Changed question"))
}
awaitItem().apply {
- eventSink(CreatePollEvents.SetAnswer(0, "Changed answer 1"))
+ eventSink(CreatePollEvent.SetAnswer(0, "Changed answer 1"))
}
awaitItem().apply {
- eventSink(CreatePollEvents.SetAnswer(1, "Changed answer 2"))
+ eventSink(CreatePollEvent.SetAnswer(1, "Changed answer 2"))
}
awaitPollLoaded(
newQuestion = "Changed question",
newAnswer1 = "Changed answer 1",
newAnswer2 = "Changed answer 2",
).apply {
- eventSink(CreatePollEvents.Save)
+ eventSink(CreatePollEvent.Save)
}
advanceUntilIdle() // Wait for the coroutine to finish
@@ -275,8 +275,8 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
- awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
+ awaitPollLoaded().eventSink(CreatePollEvent.SetAnswer(0, "A"))
+ awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvent.Save)
advanceUntilIdle() // Wait for the coroutine to finish
editPollLambda.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
@@ -296,12 +296,12 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(initial.answers.size).isEqualTo(2)
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
val answerAdded = awaitItem()
assertThat(answerAdded.answers.size).isEqualTo(3)
assertThat(answerAdded.answers[2].text).isEmpty()
- initial.eventSink(CreatePollEvents.RemoveAnswer(2))
+ initial.eventSink(CreatePollEvent.RemoveAnswer(2))
val answerRemoved = awaitItem()
assertThat(answerRemoved.answers.size).isEqualTo(2)
}
@@ -314,7 +314,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
+ initial.eventSink(CreatePollEvent.SetQuestion("A question?"))
val questionSet = awaitItem()
assertThat(questionSet.question).isEqualTo("A question?")
}
@@ -327,7 +327,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1"))
+ initial.eventSink(CreatePollEvent.SetAnswer(0, "This is answer 1"))
val answerSet = awaitItem()
assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1")
}
@@ -340,7 +340,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed))
+ initial.eventSink(CreatePollEvent.SetPollKind(PollKind.Undisclosed))
val kindSet = awaitItem()
assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed)
}
@@ -355,10 +355,10 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(initial.canAddAnswer).isTrue()
repeat(17) {
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
assertThat(awaitItem().canAddAnswer).isTrue()
}
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
assertThat(awaitItem().canAddAnswer).isFalse()
}
}
@@ -371,7 +371,7 @@ class CreatePollPresenterTest {
}.test {
val initial = awaitItem()
assertThat(initial.answers.all { it.canDelete }).isFalse()
- initial.eventSink(CreatePollEvents.AddAnswer)
+ initial.eventSink(CreatePollEvent.AddAnswer)
assertThat(awaitItem().answers.all { it.canDelete }).isTrue()
}
}
@@ -383,7 +383,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241)))
+ initial.eventSink(CreatePollEvent.SetAnswer(0, "A".repeat(241)))
assertThat(awaitItem().answers.first().text.length).isEqualTo(240)
}
}
@@ -396,7 +396,7 @@ class CreatePollPresenterTest {
}.test {
val initial = awaitItem()
assertThat(navUpInvocationsCount).isEqualTo(0)
- initial.eventSink(CreatePollEvents.NavBack)
+ initial.eventSink(CreatePollEvent.NavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@@ -410,7 +410,7 @@ class CreatePollPresenterTest {
val initial = awaitItem()
assertThat(navUpInvocationsCount).isEqualTo(0)
assertThat(initial.showBackConfirmation).isFalse()
- initial.eventSink(CreatePollEvents.ConfirmNavBack)
+ initial.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@@ -422,11 +422,11 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
val initial = awaitItem()
- initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
+ initial.eventSink(CreatePollEvent.SetQuestion("Non blank"))
assertThat(awaitItem().showBackConfirmation).isFalse()
- initial.eventSink(CreatePollEvents.ConfirmNavBack)
+ initial.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(awaitItem().showBackConfirmation).isTrue()
- initial.eventSink(CreatePollEvents.HideConfirmation)
+ initial.eventSink(CreatePollEvent.HideConfirmation)
assertThat(awaitItem().showBackConfirmation).isFalse()
assertThat(navUpInvocationsCount).isEqualTo(0)
}
@@ -442,7 +442,7 @@ class CreatePollPresenterTest {
val loaded = awaitPollLoaded()
assertThat(navUpInvocationsCount).isEqualTo(0)
assertThat(loaded.showBackConfirmation).isFalse()
- loaded.eventSink(CreatePollEvents.ConfirmNavBack)
+ loaded.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@@ -455,11 +455,11 @@ class CreatePollPresenterTest {
}.test {
awaitDefaultItem()
val loaded = awaitPollLoaded()
- loaded.eventSink(CreatePollEvents.SetQuestion("CHANGED"))
+ loaded.eventSink(CreatePollEvent.SetQuestion("CHANGED"))
assertThat(awaitItem().showBackConfirmation).isFalse()
- loaded.eventSink(CreatePollEvents.ConfirmNavBack)
+ loaded.eventSink(CreatePollEvent.ConfirmNavBack)
assertThat(awaitItem().showBackConfirmation).isTrue()
- loaded.eventSink(CreatePollEvents.HideConfirmation)
+ loaded.eventSink(CreatePollEvent.HideConfirmation)
assertThat(awaitItem().showBackConfirmation).isFalse()
assertThat(navUpInvocationsCount).isEqualTo(0)
}
@@ -474,7 +474,7 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
+ awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false))
awaitDeleteConfirmation()
assert(redactEventLambda).isNeverCalled()
}
@@ -489,8 +489,8 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
- awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation)
+ awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false))
+ awaitDeleteConfirmation().eventSink(CreatePollEvent.HideConfirmation)
awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse()
}
@@ -507,8 +507,8 @@ class CreatePollPresenterTest {
presenter.present()
}.test {
awaitDefaultItem()
- awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
- awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true))
+ awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false))
+ awaitDeleteConfirmation().eventSink(CreatePollEvent.Delete(confirmed = true))
awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse()
}
diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
index 438d4511996..277508681e7 100644
--- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
+++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt
@@ -220,6 +220,7 @@ class PollContentStateFactoryTest {
votes = votes,
endTime = endTime,
isEdited = false,
+ threadInfo = null,
)
private fun aPollContentState(
diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
index 82ff1e7eddc..5a59d9be8ae 100644
--- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
@@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToAddAccount()
+ fun navigateToLinkNewDevice()
fun navigateToBugReport()
fun navigateToSecureBackup()
fun navigateToRoomNotificationSettings(roomId: RoomId)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
index a7b3ac0acfb..a8e33b7d054 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
@@ -146,6 +146,7 @@ class PreferencesFlowNode(
backstack.push(NavTarget.About)
}
+ // TCHAP - Add FAQ_URL in Preferences
override fun onOpenFAQ(activity: Activity, darkTheme: Boolean) {
activity.openUrlInChromeCustomTab(
null,
@@ -174,6 +175,10 @@ class PreferencesFlowNode(
backstack.push(NavTarget.Labs)
}
+ override fun navigateToLinkNewDevice() {
+ callback.navigateToLinkNewDevice()
+ }
+
override fun navigateToUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
index b518dae4d1a..c2b51973d02 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt
@@ -234,7 +234,7 @@ private fun VideoQualitySelectorDialog(
supportingContent = {
Text(
text = subtitle,
- style = ElementTheme.materialTypography.bodyMedium,
+ style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
},
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
index 3bf4f375d5b..1804d7e0707 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt
@@ -21,4 +21,5 @@ sealed interface DeveloperSettingsEvents {
data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents
+ data object VacuumStores : DeveloperSettingsEvents
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
index 52e522dc898..a0d96be540f 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt
@@ -29,22 +29,28 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
+import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
+import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
+import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
@@ -61,6 +67,9 @@ class DeveloperSettingsPresenter(
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
+ private val vacuumStoresUseCase: VacuumStoresUseCase,
+ private val databaseSizesUseCase: GetDatabaseSizesUseCase,
+ private val fileSizeFormatter: FileSizeFormatter,
) : Presenter {
@Composable
override fun present(): DeveloperSettingsState {
@@ -71,6 +80,9 @@ class DeveloperSettingsPresenter(
val cacheSize = remember {
mutableStateOf>(AsyncData.Uninitialized)
}
+ val databaseSizes = remember {
+ mutableStateOf>>(AsyncData.Uninitialized)
+ }
val clearCacheAction = remember {
mutableStateOf>(AsyncAction.Uninitialized)
}
@@ -94,6 +106,7 @@ class DeveloperSettingsPresenter(
}
LaunchedEffect(Unit) {
+ computeDatabaseSizes(databaseSizes)
featureFlagService.getAvailableFeatures()
.run {
// Never display room directory search in release builds for Play Store
@@ -151,12 +164,16 @@ class DeveloperSettingsPresenter(
is DeveloperSettingsEvents.SetShowColorPicker -> {
showColorPicker = event.show
}
+ DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
+ vacuumStoresUseCase()
+ }
}
}
return DeveloperSettingsState(
features = featureUiModels,
cacheSize = cacheSize.value,
+ databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
rageshakeState = rageshakeState,
customElementCallBaseUrlState = CustomElementCallBaseUrlState(
@@ -209,6 +226,27 @@ class DeveloperSettingsPresenter(
}.runCatchingUpdatingState(cacheSize)
}
+ private fun CoroutineScope.computeDatabaseSizes(databaseSizes: MutableState>>) = launch {
+ suspend {
+ databaseSizesUseCase(sessionId).getOrThrow().let { sizes ->
+ buildMap {
+ sizes.stateStore?.let { stateStoreSize ->
+ put("State store", fileSizeFormatter.format(stateStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ sizes.eventCacheStore?.let { eventCacheStoreSize ->
+ put("Event cache store", fileSizeFormatter.format(eventCacheStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ sizes.mediaStore?.let { mediaStoreSize ->
+ put("Media store", fileSizeFormatter.format(mediaStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ sizes.cryptoStore?.let { cryptoStoreSize ->
+ put("Crypto store", fileSizeFormatter.format(cryptoStoreSize.into(ByteUnit.BYTES), useShortFormat = true))
+ }
+ }
+ }.toImmutableMap()
+ }.runCatchingUpdatingState(databaseSizes)
+ }
+
private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch {
suspend {
clearCacheUseCase()
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
index f97270dc7a0..920c8ec95c2 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt
@@ -15,10 +15,12 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
data class DeveloperSettingsState(
val features: ImmutableList,
val cacheSize: AsyncData,
+ val databaseSizes: AsyncData>,
val rageshakeState: RageshakePreferencesState,
val clearCacheAction: AsyncAction,
val customElementCallBaseUrlState: CustomElementCallBaseUrlState,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
index ea16ed9f0f5..9ac4fdfcc83 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
import io.element.android.libraries.matrix.api.tracing.TraceLogPack
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
open class DeveloperSettingsStateProvider : PreviewParameterProvider {
@@ -47,6 +48,7 @@ fun aDeveloperSettingsState(
features = aFeatureUiModelList(),
rageshakeState = aRageshakePreferencesState(),
cacheSize = AsyncData.Success("1.2 MB"),
+ databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
customElementCallBaseUrlState = customElementCallBaseUrlState,
tracingLogLevel = AsyncData.Success(LogLevelItem.INFO),
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
index 6d34e97f636..444a391d43e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt
@@ -9,6 +9,7 @@
package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
@@ -146,6 +147,33 @@ fun DeveloperSettingsView(
}
val cache = state.cacheSize
PreferenceCategory(title = "Cache") {
+ ListItem(
+ headlineContent = { Text("Database sizes") },
+ supportingContent = {
+ if (state.databaseSizes.isLoading()) {
+ Text("Computing...")
+ } else {
+ val dbSizes = state.databaseSizes.dataOrNull()
+ if (dbSizes != null && dbSizes.isNotEmpty()) {
+ Column {
+ for ((dbName, size) in dbSizes) {
+ Text("$dbName: $size")
+ }
+ }
+ } else {
+ Text("Unknown")
+ }
+ }
+ }
+ )
+ ListItem(
+ headlineContent = {
+ Text("Vacuum stores")
+ },
+ onClick = {
+ state.eventSink(DeveloperSettingsEvents.VacuumStores)
+ }
+ )
ListItem(
headlineContent = {
Text("Clear cache")
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
index e643bfaf36d..89939c55343 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
@@ -52,6 +52,7 @@ class PreferencesRootNode(
fun navigateToLockScreenSettings()
fun navigateToAdvancedSettings()
fun navigateToLabs()
+ fun navigateToLinkNewDevice()
fun navigateToUserProfile(matrixUser: MatrixUser)
fun navigateToBlockedUsers()
fun startSignOutFlow()
@@ -97,6 +98,7 @@ class PreferencesRootNode(
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
onOpenAdvancedSettings = callback::navigateToAdvancedSettings,
onOpenLabs = callback::navigateToLabs,
+ onLinkNewDeviceClick = callback::navigateToLinkNewDevice,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = callback::navigateToNotificationSettings,
onOpenLockScreenSettings = callback::navigateToLockScreenSettings,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
index d9b8b7ab2ab..4fd23220459 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
@@ -69,6 +69,9 @@ class PreferencesRootPresenter(
val isMultiAccountEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
}.collectAsState(initial = false)
+ val showLinkNewDevice by remember {
+ featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
+ }.collectAsState(initial = false)
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
@@ -151,6 +154,7 @@ class PreferencesRootPresenter(
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,
canReportBug = canReportBug,
+ showLinkNewDevice = showLinkNewDevice,
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
index b077413f23d..e88c83ed679 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
@@ -26,6 +26,7 @@ data class PreferencesRootState(
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val canReportBug: Boolean,
+ val showLinkNewDevice: Boolean,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
index 528fd1a9112..872e793c6c8 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
@@ -32,6 +32,7 @@ fun aPreferencesRootState(
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,
+ showLinkNewDevice = true,
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
index 6ba6cec4e50..08132ee3e94 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
@@ -54,6 +54,7 @@ fun PreferencesRootView(
onAddAccountClick: () -> Unit,
onSecureBackupClick: () -> Unit,
onManageAccountClick: (url: String) -> Unit,
+ onLinkNewDeviceClick: () -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
@@ -105,6 +106,7 @@ fun PreferencesRootView(
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
+ onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
@@ -200,8 +202,16 @@ private fun ColumnScope.ManageAppSection(
private fun ColumnScope.ManageAccountSection(
state: PreferencesRootState,
onManageAccountClick: (url: String) -> Unit,
+ onLinkNewDeviceClick: () -> Unit,
onOpenBlockedUsers: () -> Unit,
) {
+ if (state.showLinkNewDevice) {
+ ListItem(
+ headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) },
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())),
+ onClick = onLinkNewDeviceClick,
+ )
+ }
state.accountManagementUrl?.let { url ->
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) },
@@ -367,6 +377,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenAbout = {},
onSecureBackupClick = {},
onManageAccountClick = {},
+ onLinkNewDeviceClick = {},
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {},
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt
new file mode 100644
index 00000000000..1d0de56f091
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.preferences.impl.tasks
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.matrix.api.MatrixClient
+import timber.log.Timber
+
+fun interface VacuumStoresUseCase {
+ suspend operator fun invoke()
+}
+
+@ContributesBinding(AppScope::class)
+class DefaultVacuumStoresUseCase(
+ private val matrixClient: MatrixClient,
+) : VacuumStoresUseCase {
+ override suspend fun invoke() {
+ matrixClient.performDatabaseVacuum()
+ .onFailure { Timber.e(it, "Failed to vacuum stores") }
+ }
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt
similarity index 70%
rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt
rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt
index f7f2ffceb40..d88eb759631 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt
@@ -10,10 +10,10 @@ package io.element.android.features.preferences.impl.user.editprofile
import io.element.android.libraries.matrix.ui.media.AvatarAction
-sealed interface EditUserProfileEvents {
- data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
- data class UpdateDisplayName(val name: String) : EditUserProfileEvents
- data object Exit : EditUserProfileEvents
- data object Save : EditUserProfileEvents
- data object CloseDialog : EditUserProfileEvents
+sealed interface EditUserProfileEvent {
+ data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvent
+ data class UpdateDisplayName(val name: String) : EditUserProfileEvent
+ data object Exit : EditUserProfileEvent
+ data object Save : EditUserProfileEvent
+ data object CloseDialog : EditUserProfileEvent
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
index 1b3b2b072e5..51a9e2b67af 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt
@@ -38,7 +38,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
-import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsEvent
import io.element.android.libraries.permissions.api.PermissionsPresenter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -116,22 +116,22 @@ class EditUserProfilePresenter(
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
- fun handleEvent(event: EditUserProfileEvents) {
+ fun handleEvent(event: EditUserProfileEvent) {
when (event) {
- is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
+ is EditUserProfileEvent.Save -> localCoroutineScope.saveChanges(
name = userDisplayName,
avatarUri = userAvatarUri?.toUri(),
currentUser = matrixUser,
action = saveAction,
)
- is EditUserProfileEvents.HandleAvatarAction -> {
+ is EditUserProfileEvent.HandleAvatarAction -> {
when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
- cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
+ cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions)
}
AvatarAction.Remove -> {
temporaryUriDeleter.delete(userAvatarUri?.toUri())
@@ -139,8 +139,8 @@ class EditUserProfilePresenter(
}
}
}
- is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
- EditUserProfileEvents.Exit -> {
+ is EditUserProfileEvent.UpdateDisplayName -> userDisplayName = event.name
+ EditUserProfileEvent.Exit -> {
when (saveAction.value) {
is AsyncAction.Confirming -> {
// Close the dialog right now
@@ -161,7 +161,7 @@ class EditUserProfilePresenter(
}
}
}
- EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
+ EditUserProfileEvent.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
index 767e37ad8db..23f9c065cc6 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt
@@ -23,5 +23,5 @@ data class EditUserProfileState(
val saveButtonEnabled: Boolean,
val saveAction: AsyncAction,
val cameraPermissionState: PermissionsState,
- val eventSink: (EditUserProfileEvents) -> Unit
+ val eventSink: (EditUserProfileEvent) -> Unit
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
index a59b6001961..d6fee95ad2e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt
@@ -33,7 +33,7 @@ fun aEditUserProfileState(
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
- eventSink: (EditUserProfileEvents) -> Unit = {},
+ eventSink: (EditUserProfileEvent) -> Unit = {},
) = EditUserProfileState(
showMatrixId = false,
userId = userId,
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
index 1ee8b092735..f2c69bc0f7f 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt
@@ -34,6 +34,7 @@ import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -46,7 +47,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
-import io.element.android.libraries.matrix.ui.components.EditableAvatarView
+import io.element.android.libraries.matrix.ui.components.AvatarPickerState
+import io.element.android.libraries.matrix.ui.components.AvatarPickerView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
@@ -67,7 +69,7 @@ fun EditUserProfileView(
fun onBackClick() {
focusManager.clearFocus()
- state.eventSink(EditUserProfileEvents.Exit)
+ state.eventSink(EditUserProfileEvent.Exit)
}
BackHandler(
@@ -86,7 +88,7 @@ fun EditUserProfileView(
enabled = state.saveButtonEnabled,
onClick = {
focusManager.clearFocus()
- state.eventSink(EditUserProfileEvents.Save)
+ state.eventSink(EditUserProfileEvent.Save)
},
)
}
@@ -102,23 +104,19 @@ fun EditUserProfileView(
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
- EditableAvatarView(
- matrixId = state.userId.value,
- displayName = state.displayName,
- avatarUrl = state.userAvatarUrl,
- avatarSize = AvatarSize.EditProfileDetails,
- avatarType = AvatarType.User,
- onAvatarClick = { onAvatarClick() },
- modifier = Modifier.align(Alignment.CenterHorizontally),
- )
- Spacer(modifier = Modifier.height(16.dp))
+ val avatarPickerState = remember(state.userAvatarUrl) {
+ val size = AvatarSize.EditProfileDetails
+ val type = AvatarType.User
+ AvatarPickerState.Selected(
+ avatarData = AvatarData(id = state.userId.value, name = state.displayName, size = size, url = state.userAvatarUrl),
+ type = type
+ )
+ }
- // TCHAP show displayName as Text instead of TextField
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = state.displayName,
- style = ElementTheme.typography.fontBodyLgRegular,
- textAlign = TextAlign.Center,
+ AvatarPickerView(
+ state = avatarPickerState,
+ onClick = ::onAvatarClick,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
)
// TCHAP hide the Matrix Id depending of showMatrixId feature flag
if (state.showMatrixId) {
@@ -130,13 +128,28 @@ fun EditUserProfileView(
textAlign = TextAlign.Center,
)
}
+ Spacer(modifier = Modifier.height(40.dp))
+// TextField(
+// label = stringResource(R.string.screen_edit_profile_display_name),
+// value = state.displayName,
+// placeholder = stringResource(CommonStrings.common_room_name_placeholder),
+// singleLine = true,
+// onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) },
+// )
+ // TCHAP show displayName as Text instead of TextField
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = state.displayName,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ textAlign = TextAlign.Center,
+ )
}
AvatarActionBottomSheet(
actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
- onSelectAction = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) }
+ onSelectAction = { state.eventSink(EditUserProfileEvent.HandleAvatarAction(it)) }
)
AsyncActionView(
@@ -150,8 +163,9 @@ fun EditUserProfileView(
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
- onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) },
- onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }
+ onSaveClick = { state.eventSink(EditUserProfileEvent.Save) },
+ onDiscardClick = { state.eventSink(EditUserProfileEvent.Exit) },
+ onDismiss = { state.eventSink(EditUserProfileEvent.CloseDialog) },
)
}
}
@@ -159,7 +173,7 @@ fun EditUserProfileView(
onSuccess = { onEditProfileSuccess() },
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
- onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
+ onErrorDismiss = { state.eventSink(EditUserProfileEvent.CloseDialog) },
)
}
PermissionsView(
diff --git a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml
index b1f615e6971..9f69227beca 100644
--- a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml
+++ b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml
@@ -3,4 +3,5 @@
"Optimize media quality"
"Automatically optimize images for faster uploads and smaller file sizes."
"Optimize image upload quality"
+ "Try out our latest ideas in development. These features are not finalized; they may be unstable, may change."
diff --git a/features/preferences/impl/src/main/res/values-hr/translations.xml b/features/preferences/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..250af33d491
--- /dev/null
+++ b/features/preferences/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,82 @@
+
+
+ "Kako biste bili sigurni da nikada nećete propustiti važan poziv, promijenite postavke kako biste omogućili obavijesti preko cijelog zaslona kada je telefon zaključan."
+ "Poboljšajte svoje iskustvo poziva"
+ "Odaberite kako želite primati obavijesti"
+ "Način rada za razvojne inženjere"
+ "Omogućite pristup značajkama i funkcionalnostima za razvojne inženjere."
+ "Prilagođeni osnovni URL za Element Call"
+ "Postavite prilagođeni osnovni URL za Element Call."
+ "Nevažeći URL; provjerite jeste li uključili protokol (http/https) i ispravnu adresu."
+ "Sakrij avatare u zahtjevima za poziv u sobu"
+ "Sakrij preglede medija na vremenskoj traci"
+ "Laboratoriji"
+ "Brže prenesite fotografije i videozapise te smanjite potrošnju podataka"
+ "Optimiziraj kvalitetu medija"
+ "Moderiranje i sigurnost"
+ "Automatski optimizirajte slike za brži prijenos i manje veličine datoteka."
+ "Optimiziraj kvalitetu prijenosa slika"
+ "%1$s. Ovdje dodirnite za promjenu."
+ "Visoka (1080p)"
+ "Niska (480p)"
+ "Standardna (720p)"
+ "Kvaliteta prijenosa videozapisa"
+ "Pružatelj push obavijesti"
+ "Onemogućite uređivač obogaćenog teksta kako biste ručno tipkali Markdown."
+ "Potvrde o čitanju"
+ "Ako je to isključeno, vaše potvrde o čitanju neće se slati nikome. I dalje ćete primati potvrde o čitanju od drugih korisnika."
+ "Podijeli prisutnost"
+ "Ako je to isključeno, nećete moći slati ili primati potvrde o čitanju ili obavijesti o tipkanju."
+ "Uvijek sakrij"
+ "Uvijek prikaži"
+ "U privatnim sobama"
+ "Skriveni medij uvijek se može prikazati tako se da se dodirne"
+ "Prikaži medije na vremenskoj traci"
+ "Omogući opciju za prikaz izvora poruke na vremenskoj traci."
+ "Nemate blokiranih korisnika"
+ "Odblokiraj"
+ "Moći ćete ponovno vidjeti sve njihove poruke."
+ "Odblokiraj korisnika"
+ "Deblokiranje…"
+ "Ime za prikaz"
+ "Vaše ime za prikaz"
+ "Došlo je do nepoznate pogreške i informacije se nisu mogle promijeniti."
+ "Nije moguće ažurirati profil"
+ "Uredi profil"
+ "Ažuriranje profila…"
+ "Omogući odgovore u nizu"
+ "Aplikacija će se ponovno pokrenuti kako bi se primijenila ova promjena."
+ "Isprobajte naše najnovije ideje u razvoju. Ove značajke nisu finalizirane; mogu biti nestabilne i mijenjati se."
+ "Jeste li spremni za eksperimentiranje?"
+ "Laboratoriji"
+ "Dodatne postavke"
+ "Audiopozivi i videopozivi"
+ "Neusklađenost konfiguracije"
+ "Pojednostavili smo postavke obavijesti kako bismo olakšali pronalaženje mogućnosti. Neke prilagođene postavke koje ste odabrali u prošlosti nisu ovdje prikazane, ali su i dalje aktivne.
+
+Ako nastavite, neke od vaših postavki mogu se promijeniti."
+ "Izravni razgovori"
+ "Prilagođena postavka po razgovoru"
+ "Došlo je do pogreške prilikom ažuriranja postavke obavijesti."
+ "Sve poruke"
+ "Samo spominjanja i ključne riječi"
+ "U izravnim razgovorima obavijesti me za"
+ "U grupnim chatovima obavijesti me za"
+ "Omogući obavijesti na ovom uređaju"
+ "Konfiguracija nije ispravljena, pokušajte ponovno."
+ "Grupni razgovori"
+ "Pozivnice"
+ "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; možda nećete dobiti obavijesti u nekim sobama."
+ "Spominjanja"
+ "Sve"
+ "Spominjanja"
+ "Obavijesti me za"
+ "Obavijesti me o sobi @soba"
+ "Kako biste primali obavijesti, promijenite %1$s."
+ "postavke sustava"
+ "Obavijesti sustava su isključene"
+ "Obavijesti"
+ "Povijest push obavijesti"
+ "Rješavanje problema"
+ "Rješavanje problema s obavijestima"
+
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
index 7a950629bea..963d7846b45 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt
@@ -50,6 +50,7 @@ class DefaultPreferencesEntryPointTest {
}
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError()
+ override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError()
override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
index fe2d8445fde..1fcf9bff705 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt
@@ -17,15 +17,20 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
+import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
+import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
+import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -34,6 +39,7 @@ import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -55,7 +61,12 @@ class DeveloperSettingsPresenterTest {
)
}
val presenter = createDeveloperSettingsPresenter(
- featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult)
+ featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
+ databaseSizesUseCase = GetDatabaseSizesUseCase {
+ Result.success(
+ SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes)
+ )
+ }
)
presenter.test {
awaitItem().also { state ->
@@ -78,6 +89,14 @@ class DeveloperSettingsPresenterTest {
}
awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
+ assertThat(state.databaseSizes.dataOrNull()).isEqualTo(
+ persistentMapOf(
+ "State store" to "10485760 Bytes",
+ "Event cache store" to "10485760 Bytes",
+ "Media store" to "10485760 Bytes",
+ "Crypto store" to "10485760 Bytes"
+ )
+ )
}
getAvailableFeaturesResult.assertions().isCalledOnce()
.with(value(false), value(false))
@@ -212,6 +231,23 @@ class DeveloperSettingsPresenterTest {
}
}
+ @Test
+ fun `present - VacuumStores action invokes the VacuumStoresUseCase`() = runTest {
+ var vacuumCalled = false
+ val presenter = createDeveloperSettingsPresenter(
+ vacuumStoresUseCase = VacuumStoresUseCase {
+ vacuumCalled = true
+ }
+ )
+ presenter.test {
+ val state = awaitItem()
+ assertThat(vacuumCalled).isFalse()
+ state.eventSink(DeveloperSettingsEvents.VacuumStores)
+ skipItems(1)
+ assertThat(vacuumCalled).isTrue()
+ }
+ }
+
private fun createDeveloperSettingsPresenter(
sessionId: SessionId = A_SESSION_ID,
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
@@ -230,6 +266,8 @@ class DeveloperSettingsPresenterTest {
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
+ vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
+ databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
sessionId = sessionId,
@@ -240,6 +278,9 @@ class DeveloperSettingsPresenterTest {
appPreferencesStore = preferencesStore,
buildMeta = buildMeta,
enterpriseService = enterpriseService,
+ vacuumStoresUseCase = vacuumStoresUseCase,
+ databaseSizesUseCase = databaseSizesUseCase,
+ fileSizeFormatter = FakeFileSizeFormatter(),
)
}
}
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
index e812bf650d2..3854e3f4a1a 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt
@@ -113,7 +113,7 @@ class DeveloperSettingsViewTest {
eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG))
}
- @Config(qualifiers = "h2000dp")
+ @Config(qualifiers = "h2200dp")
@Test
fun `clicking on clear cache emits the expected event`() {
val eventsRecorder = EventsRecorder()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
index c0d7b6e817f..a83ec9af794 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
@@ -88,6 +88,7 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.accountManagementUrl).isNull()
assertThat(loadedState.devicesManagementUrl).isNull()
assertThat(loadedState.showAnalyticsSettings).isFalse()
+ assertThat(loadedState.showLinkNewDevice).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.canDeactivateAccount).isTrue()
assertThat(loadedState.canReportBug).isTrue()
@@ -259,6 +260,22 @@ class PreferencesRootPresenterTest {
}
}
+ @Test
+ fun `present - link new device`() = runTest {
+ createPresenter(
+ matrixClient = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ canDeactivateAccountResult = { true },
+ ),
+ featureFlagService = FakeFeatureFlagService(
+ initialState = mapOf(FeatureFlags.QrCodeLogin.key to true)
+ ),
+ ).test {
+ val state = awaitFirstItem()
+ assertThat(state.showLinkNewDevice).isTrue()
+ }
+ }
+
private suspend fun ReceiveTurbine.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
index 2cd2a9d01d9..cdbe0a542e1 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt
@@ -58,8 +58,6 @@ class EditUserProfilePresenterTest {
private val userAvatarUri: Uri = mockk()
private val anotherAvatarUri: Uri = mockk()
- private val fakeFileContents = ByteArray(2)
-
@Before
fun setup() {
fakePickerProvider = FakePickerProvider()
@@ -128,7 +126,7 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.Exit)
+ initialState.eventSink(EditUserProfileEvent.Exit)
closeLambda.assertions().isCalledOnce()
}
}
@@ -143,21 +141,21 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("New name"))
val withUpdatedName = awaitItem()
- withUpdatedName.eventSink(EditUserProfileEvents.Exit)
+ withUpdatedName.eventSink(EditUserProfileEvent.Exit)
val withConfirmation = awaitItem()
assertThat(withConfirmation.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
// Cancel
- withConfirmation.eventSink(EditUserProfileEvents.CloseDialog)
+ withConfirmation.eventSink(EditUserProfileEvent.CloseDialog)
val afterCancel = awaitItem()
assertThat(afterCancel.saveAction).isEqualTo(AsyncAction.Uninitialized)
// Try again and confirm
- afterCancel.eventSink(EditUserProfileEvents.Exit)
+ afterCancel.eventSink(EditUserProfileEvent.Exit)
val withConfirmation2 = awaitItem()
assertThat(withConfirmation2.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
closeLambda.assertions().isNeverCalled()
- withConfirmation2.eventSink(EditUserProfileEvents.Exit)
+ withConfirmation2.eventSink(EditUserProfileEvent.Exit)
// Dialog is closed
val finalState = awaitItem()
assertThat(finalState.saveAction).isEqualTo(AsyncAction.Uninitialized)
@@ -178,17 +176,17 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.displayName).isEqualTo("Name")
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name II")
assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL)
}
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name III"))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name III")
assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL)
}
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(displayName).isEqualTo("Name III")
assertThat(userAvatarUrl).isNull()
@@ -209,7 +207,7 @@ class EditUserProfilePresenterTest {
presenter.test {
val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL)
}
@@ -233,7 +231,7 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithAskingPermission = awaitItem()
assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue()
fakePermissionsPresenter.setPermissionGranted()
@@ -243,7 +241,7 @@ class EditUserProfilePresenterTest {
assertThat(stateWithNewAvatar.userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL)
// Do it again, no permission is requested
fakePickerProvider.givenResult(userAvatarUri)
- stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
+ stateWithNewAvatar.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
deleteCallback.assertions().isCalledExactly(2).withSequence(
@@ -268,22 +266,22 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
@@ -309,22 +307,22 @@ class EditUserProfilePresenterTest {
val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// If it's reverted then the save disables again
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name"))
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name"))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
// Make a change...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
}
// Revert it...
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
awaitItem().apply {
assertThat(saveButtonEnabled).isFalse()
}
@@ -348,9 +346,9 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("New name"))
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
+ initialState.eventSink(EditUserProfileEvent.Save)
consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled }
assertThat(matrixClient.setDisplayNameCalled).isTrue()
assertThat(matrixClient.removeAvatarCalled).isTrue()
@@ -369,8 +367,8 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName(" Name "))
+ initialState.eventSink(EditUserProfileEvent.Save)
consumeItemsUntilTimeout()
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
@@ -388,8 +386,8 @@ class EditUserProfilePresenterTest {
)
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName(""))
+ initialState.eventSink(EditUserProfileEvent.Save)
assertThat(matrixClient.setDisplayNameCalled).isFalse()
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(matrixClient.removeAvatarCalled).isFalse()
@@ -401,7 +399,7 @@ class EditUserProfilePresenterTest {
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
val matrixClient = FakeMatrixClient()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
- givenPickerReturnsFile()
+ val tmpFile = givenPickerReturnsFile()
val presenter = createEditUserProfilePresenter(
matrixClient = matrixClient,
matrixUser = user,
@@ -409,12 +407,16 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
),
)
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
- initialState.eventSink(EditUserProfileEvents.Save)
- consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
- assertThat(matrixClient.uploadAvatarCalled).isTrue()
+ try {
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.Save)
+ consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled }
+ assertThat(matrixClient.uploadAvatarCalled).isTrue()
+ }
+ } finally {
+ tmpFile.delete()
}
}
@@ -433,8 +435,8 @@ class EditUserProfilePresenterTest {
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
presenter.test {
val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ initialState.eventSink(EditUserProfileEvent.Save)
skipItems(2)
assertThat(matrixClient.uploadAvatarCalled).isFalse()
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
@@ -447,7 +449,7 @@ class EditUserProfilePresenterTest {
val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
}
- saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name"))
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.UpdateDisplayName("New name"))
}
@Test
@@ -456,39 +458,47 @@ class EditUserProfilePresenterTest {
val matrixClient = FakeMatrixClient().apply {
givenRemoveAvatarResult(Result.failure(RuntimeException("!")))
}
- saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove))
}
@Test
fun `present - sets save action to failure if setting avatar fails`() = runTest {
- givenPickerReturnsFile()
+ val tmpFile = givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenUploadAvatarResult(Result.failure(RuntimeException("!")))
}
- saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ try {
+ saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto))
+ } finally {
+ tmpFile.delete()
+ }
}
@Test
fun `present - CloseDialog resets save action state`() = runTest {
- givenPickerReturnsFile()
+ val tmpFile = givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
}
val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
- presenter.test {
- val initialState = awaitItem()
- initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
- initialState.eventSink(EditUserProfileEvents.Save)
- skipItems(2)
- assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
- initialState.eventSink(EditUserProfileEvents.CloseDialog)
- assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ try {
+ presenter.test {
+ val initialState = awaitItem()
+ initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("foo"))
+ initialState.eventSink(EditUserProfileEvent.Save)
+ skipItems(2)
+ assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
+ initialState.eventSink(EditUserProfileEvent.CloseDialog)
+ assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
+ }
+ } finally {
+ tmpFile.delete()
}
}
- private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
+ private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvent) {
val presenter = createEditUserProfilePresenter(
matrixUser = matrixUser,
matrixClient = matrixClient,
@@ -499,27 +509,25 @@ class EditUserProfilePresenterTest {
presenter.test {
val initialState = awaitItem()
initialState.eventSink(event)
- initialState.eventSink(EditUserProfileEvents.Save)
+ initialState.eventSink(EditUserProfileEvent.Save)
skipItems(1)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
- private fun givenPickerReturnsFile() {
- mockkStatic(File::readBytes)
- val processedFile: File = mockk {
- every { readBytes() } returns fakeFileContents
- }
+ private fun givenPickerReturnsFile(): File {
+ val file = File.createTempFile("test", "jpg")
fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(
Result.success(
MediaUploadInfo.AnyFile(
- file = processedFile,
+ file = file,
fileInfo = mockk(),
)
)
)
+ return file
}
companion object {
diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt
index f4c71443502..728e05ee7e7 100644
--- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt
+++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt
@@ -34,45 +34,45 @@ class EditUserProfileViewTest {
@Test
fun `clicking on back emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
eventSink = eventsRecorder,
),
)
rule.pressBack()
- eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Exit)
}
@Test
- fun `clicking on cancel exit emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ fun `clicking on save from the exit confirmation dialog emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_cancel)
- eventsRecorder.assertSingle(EditUserProfileEvents.CloseDialog)
+ rule.clickOn(CommonStrings.action_save, inDialog = true)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Save)
}
@Test
- fun `clicking on OK exit emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ fun `clicking on discard exit emits the expected event`() {
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_ok)
- eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
+ rule.clickOn(CommonStrings.action_discard)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Exit)
}
@Test
fun `clicking on save emits the expected event`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
rule.setEditUserProfileView(
aEditUserProfileState(
saveButtonEnabled = true,
@@ -81,12 +81,12 @@ class EditUserProfileViewTest {
),
)
rule.clickOn(CommonStrings.action_save)
- eventsRecorder.assertSingle(EditUserProfileEvents.Save)
+ eventsRecorder.assertSingle(EditUserProfileEvent.Save)
}
@Test
fun `clicking on avatar opens the bottom sheet dialog`() {
- val eventsRecorder = EventsRecorder()
+ val eventsRecorder = EventsRecorder()
val actions = listOf(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
@@ -110,7 +110,7 @@ class EditUserProfileViewTest {
@Test
fun `success invokes the expected callback`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
+ val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
rule.setEditUserProfileView(
aEditUserProfileState(
diff --git a/features/rageshake/api/src/main/res/values-hr/translations.xml b/features/rageshake/api/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..c3dfcb3203b
--- /dev/null
+++ b/features/rageshake/api/src/main/res/values-hr/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "%1$s neočekivano je prestao s radom prilikom posljednjeg korištenja. Želite li s nama podijeliti izvješće o padu?"
+ "Čini se da ljutito treseš telefon. Želiš li otvoriti zaslon s izvješćem o pogrešci?"
+ "Snažno protresi"
+ "Prag detekcije"
+
diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
index f51d699350b..f238f9d6ffc 100755
--- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
+++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt
@@ -158,6 +158,7 @@ class DefaultBugReporter(
}
if (withCrashLogs || withDevicesLogs) {
saveLogCat()
+ ?.takeIf { it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE }
?.let { logCatFile ->
compressFile(logCatFile).also {
logCatFile.safeDelete()
@@ -191,6 +192,7 @@ class DefaultBugReporter(
.addFormDataPart("label", buildMeta.versionName)
.addFormDataPart("label", buildMeta.flavorDescription)
.addFormDataPart("branch_name", buildMeta.gitBranchName)
+
userId?.let {
matrixClientProvider.getOrNull(it)?.let { client ->
val curveKey = client.encryptionService.deviceCurve25519()
@@ -390,7 +392,11 @@ class DefaultBugReporter(
) {
val logDirectory = logDirectory()
logDirectory.listFiles()
- ?.filter { it.isFile && !it.name.endsWith(LOG_CAT_FILENAME) }
+ ?.filter {
+ it.isFile &&
+ !it.name.endsWith(LOG_CAT_FILENAME) &&
+ it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE
+ }
}.orEmpty()
}
diff --git a/features/rageshake/impl/src/main/res/values-hr/translations.xml b/features/rageshake/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..e49427b2a91
--- /dev/null
+++ b/features/rageshake/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,20 @@
+
+
+ "Priloži snimku zaslona"
+ "Možete mi se obratiti ako imate bilo kakvih dodatnih pitanja."
+ "Javi mi se"
+ "Uredi snimku zaslona"
+ "Opišite problem. Što ste napravili? Što ste očekivali da će se dogoditi? Što se zapravo dogodilo. Molimo vas da što detaljnije opišete problem."
+ "Opišite problem…"
+ "Ako je moguće, molimo vas da opis bude na engleskom jeziku."
+ "Opis je prekratak; navedite više pojedinosti o tome što se dogodilo. Hvala!"
+ "Pošalji zapisnike o padu aplikacije"
+ "Dopusti zapisnike"
+ "Vaši su zapisnici preopširni pa ih nije moguće uključiti u ovo izvješće. Molimo vas da nam ih pošaljete na drugi način."
+ "Pošalji snimku zaslona"
+ "Zapisnici će biti uključeni u vašu poruku kako bismo bili sigurni da sve ispravno funkcionira. Kako biste poslali poruku bez zapisnika, isključite ovu postavku."
+ "%1$s neočekivano je prestao s radom prilikom posljednjeg korištenja. Želite li s nama podijeliti izvješće o padu?"
+ "Ako imate problema s obavijestima, prijenos pravila za slanje obavijesti može nam pomoći da utvrdimo uzrok. Imajte na umu da ta pravila mogu sadržavati privatne podatke, kao što su vaše ime za prikaz ili ključne riječi za koje želite primati obavijesti."
+ "Postavke slanja obavijesti"
+ "Prikaz zapisnika"
+
diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
index f82c57889fc..41e136a53e5 100755
--- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
+++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt
@@ -323,7 +323,7 @@ class DefaultBugReporterTest {
while (part != null) {
part.headers["Content-Disposition"]?.let { contentDisposition ->
regex.find(contentDisposition)?.groupValues?.get(1)?.let { name ->
- foundValues.put(name, part!!.body.readUtf8())
+ foundValues.put(name, part.body.readUtf8())
}
}
part = multipartReader.nextPart()
diff --git a/features/reportroom/impl/src/main/res/values-hr/translations.xml b/features/reportroom/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..50ecc7a24d8
--- /dev/null
+++ b/features/reportroom/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,8 @@
+
+
+ "Vaša je prijava uspješno poslana, ali naišli smo na problem prilikom pokušaja napuštanja sobe. Pokušajte ponovno."
+ "Sobu nije moguće napustiti"
+ "Prijavi ovu sobu svom administratoru. Ako su poruke šifrirane, vaš administrator neće ih moći pročitati."
+ "Navedite razlog prijave…"
+ "Prijavi sobu"
+
diff --git a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt
index 1e9fe6a1c4f..bd3cea0439a 100644
--- a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt
+++ b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt
@@ -8,6 +8,19 @@
package io.element.android.features.rolesandpermissions.api
-import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
-fun interface RolesAndPermissionsEntryPoint : SimpleFeatureEntryPoint
+fun interface RolesAndPermissionsEntryPoint : FeatureEntryPoint {
+ interface Callback : Plugin {
+ fun onDone()
+ }
+
+ fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: Callback,
+ ): Node
+}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt
index 2f281a596ac..5f273658579 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt
@@ -17,7 +17,11 @@ import io.element.android.libraries.di.RoomScope
@ContributesBinding(RoomScope::class)
class DefaultRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint {
- override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
- return parentNode.createNode(buildContext)
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ callback: RolesAndPermissionsEntryPoint.Callback,
+ ): Node {
+ return parentNode.createNode(buildContext, listOf(callback))
}
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt
index 5966a4f0eda..4bd88071c31 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt
@@ -14,7 +14,10 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -25,17 +28,24 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
+import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
import io.element.android.features.rolesandpermissions.impl.permissions.ChangeRoomPermissionsNode
import io.element.android.features.rolesandpermissions.impl.roles.ChangeRolesNode
import io.element.android.features.rolesandpermissions.impl.root.RolesAndPermissionsNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
+import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -44,6 +54,7 @@ import kotlinx.parcelize.Parcelize
class RolesAndPermissionsFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
+ private val room: JoinedRoom,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -66,6 +77,7 @@ class RolesAndPermissionsFlowNode(
data object ChangeRoomPermissions : NavTarget
}
+ private val callback: RolesAndPermissionsEntryPoint.Callback = callback()
private val asyncIndicatorState = AsyncIndicatorState()
override fun onBuilt() {
@@ -76,6 +88,15 @@ class RolesAndPermissionsFlowNode(
onChangeComplete(changesSaved)
}
}
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.CREATED) {
+ room.permissionsFlow(false) { perms -> perms.canEditRolesAndPermissions() }
+ .filter { canEdit -> !canEdit }
+ .first()
+ // If the user can no longer edit roles and permissions, exit the flow
+ callback.onDone()
+ }
+ }
}
private fun onChangeComplete(changesSaved: Boolean) {
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt
index 9963a751f10..546d54c21ae 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt
@@ -34,8 +34,8 @@ internal fun AnalyticsService.trackPermissionChangeAnalytics(initial: RoomPowerL
if (updated.kick != initial?.kick) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, analyticsMemberRoleForPowerLevel(updated.kick)))
}
- if (updated.sendEvents != initial?.sendEvents) {
- capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.sendEvents)))
+ if (updated.eventsDefault != initial?.eventsDefault) {
+ capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.eventsDefault)))
}
if (updated.redactEvents != initial?.redactEvents) {
capture(RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, analyticsMemberRoleForPowerLevel(updated.redactEvents)))
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt
index b356ca33d20..552f1d0ec64 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt
@@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.permissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -20,9 +21,11 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
+import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap
@@ -36,8 +39,7 @@ class ChangeRoomPermissionsPresenter(
) : Presenter {
companion object {
private fun itemsForSection(section: RoomPermissionsSection) = when (section) {
- RoomPermissionsSection.SpaceDetails,
- RoomPermissionsSection.RoomDetails -> persistentListOf(
+ RoomPermissionsSection.EditDetails -> persistentListOf(
RoomPermissionType.ROOM_NAME,
RoomPermissionType.ROOM_AVATAR,
RoomPermissionType.ROOM_TOPIC,
@@ -46,19 +48,22 @@ class ChangeRoomPermissionsPresenter(
RoomPermissionType.SEND_EVENTS,
RoomPermissionType.REDACT_EVENTS,
)
- RoomPermissionsSection.MembershipModeration -> persistentListOf(
+ RoomPermissionsSection.ManageMembers -> persistentListOf(
RoomPermissionType.INVITE,
RoomPermissionType.KICK,
RoomPermissionType.BAN,
)
+ RoomPermissionsSection.ManageSpace -> persistentListOf(
+ RoomPermissionType.SPACE_MANAGE_ROOMS,
+ )
}
private fun RoomPermissionsSection.shouldShow(isSpace: Boolean): Boolean {
return when (this) {
- RoomPermissionsSection.RoomDetails -> !isSpace
- RoomPermissionsSection.MembershipModeration -> true
+ RoomPermissionsSection.EditDetails -> true
+ RoomPermissionsSection.ManageMembers -> true
RoomPermissionsSection.MessagesAndContent -> !isSpace
- RoomPermissionsSection.SpaceDetails -> isSpace
+ RoomPermissionsSection.ManageSpace -> isSpace
}
}
@@ -73,8 +78,7 @@ class ChangeRoomPermissionsPresenter(
private var initialPermissions by mutableStateOf(null)
private var currentPermissions by mutableStateOf(null)
- private var saveAction by mutableStateOf>(AsyncAction.Uninitialized)
- private var confirmExitAction by mutableStateOf>(AsyncAction.Uninitialized)
+ private var saveAction by mutableStateOf>(AsyncAction.Uninitialized)
@Composable
override fun present(): ChangeRoomPermissionsState {
@@ -88,6 +92,10 @@ class ChangeRoomPermissionsPresenter(
derivedStateOf { initialPermissions != currentPermissions }
}
+ val ownPowerLevel by remember {
+ room.roomInfoFlow.mapState { it.powerLevelOf(room.sessionId) }
+ }.collectAsState()
+
fun handleEvent(event: ChangeRoomPermissionsEvent) {
when (event) {
is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> {
@@ -100,33 +108,33 @@ class ChangeRoomPermissionsPresenter(
RoomPermissionType.BAN -> currentPermissions?.copy(ban = powerLevel)
RoomPermissionType.INVITE -> currentPermissions?.copy(invite = powerLevel)
RoomPermissionType.KICK -> currentPermissions?.copy(kick = powerLevel)
- RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(sendEvents = powerLevel)
+ RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(eventsDefault = powerLevel)
RoomPermissionType.REDACT_EVENTS -> currentPermissions?.copy(redactEvents = powerLevel)
RoomPermissionType.ROOM_NAME -> currentPermissions?.copy(roomName = powerLevel)
RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel)
RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel)
+ RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions?.copy(spaceChild = powerLevel)
}
}
is ChangeRoomPermissionsEvent.Save -> coroutineScope.save()
is ChangeRoomPermissionsEvent.Exit -> {
- confirmExitAction = if (!hasChanges || confirmExitAction.isConfirming()) {
- AsyncAction.Success(Unit)
+ saveAction = if (!hasChanges || saveAction == AsyncAction.ConfirmingCancellation) {
+ AsyncAction.Success(false)
} else {
- AsyncAction.ConfirmingNoParams
+ AsyncAction.ConfirmingCancellation
}
}
is ChangeRoomPermissionsEvent.ResetPendingActions -> {
saveAction = AsyncAction.Uninitialized
- confirmExitAction = AsyncAction.Uninitialized
}
}
}
return ChangeRoomPermissionsState(
+ ownPowerLevel = ownPowerLevel,
currentPermissions = currentPermissions,
itemsBySection = itemsBySection,
hasChanges = hasChanges,
saveAction = saveAction,
- confirmExitAction = confirmExitAction,
eventSink = ::handleEvent,
)
}
@@ -147,7 +155,7 @@ class ChangeRoomPermissionsPresenter(
.onSuccess {
analyticsService.trackPermissionChangeAnalytics(initialPermissions, updatedRoomPowerLevels)
initialPermissions = currentPermissions
- saveAction = AsyncAction.Success(Unit)
+ saveAction = AsyncAction.Success(true)
}
.onFailure {
saveAction = AsyncAction.Failure(it)
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt
index 2dc2c816d60..535f2b4a71b 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt
@@ -18,41 +18,62 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentListOf
data class ChangeRoomPermissionsState(
+ private val ownPowerLevel: Long,
val currentPermissions: RoomPowerLevelsValues?,
val itemsBySection: ImmutableMap>,
val hasChanges: Boolean,
- val saveAction: AsyncAction,
- val confirmExitAction: AsyncAction,
+ val saveAction: AsyncAction,
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
) {
+ private val ownRole = RoomMember.Role.forPowerLevel(ownPowerLevel)
+
+ // Roles that the user can select based on their own role
+ val selectableRoles: ImmutableList = when (ownRole) {
+ is RoomMember.Role.Owner,
+ RoomMember.Role.Admin -> persistentListOf(SelectableRole.Admin, SelectableRole.Moderator, SelectableRole.Everyone)
+ RoomMember.Role.Moderator -> persistentListOf(SelectableRole.Moderator, SelectableRole.Everyone)
+ RoomMember.Role.User -> persistentListOf(SelectableRole.Everyone)
+ }
+
fun selectedRoleForType(type: RoomPermissionType): SelectableRole? {
- if (currentPermissions == null) return null
- val role = when (type) {
- RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban)
- RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite)
- RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick)
- RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.sendEvents)
- RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents)
- RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName)
- RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar)
- RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic)
- }
- return when (role) {
+ val powerLevel = currentPowerLevelForType(type = type) ?: return null
+ return when (RoomMember.Role.forPowerLevel(powerLevel)) {
is RoomMember.Role.Owner,
RoomMember.Role.Admin -> SelectableRole.Admin
RoomMember.Role.Moderator -> SelectableRole.Moderator
RoomMember.Role.User -> SelectableRole.Everyone
}
}
+
+ fun canChangePermission(type: RoomPermissionType): Boolean {
+ val currentPowerLevel = currentPowerLevelForType(type) ?: return false
+ return ownPowerLevel >= currentPowerLevel
+ }
+
+ private fun currentPowerLevelForType(type: RoomPermissionType): Long? {
+ if (currentPermissions == null) return null
+ return when (type) {
+ RoomPermissionType.BAN -> currentPermissions.ban
+ RoomPermissionType.INVITE -> currentPermissions.invite
+ RoomPermissionType.KICK -> currentPermissions.kick
+ RoomPermissionType.SEND_EVENTS -> currentPermissions.eventsDefault
+ RoomPermissionType.REDACT_EVENTS -> currentPermissions.redactEvents
+ RoomPermissionType.ROOM_NAME -> currentPermissions.roomName
+ RoomPermissionType.ROOM_AVATAR -> currentPermissions.roomAvatar
+ RoomPermissionType.ROOM_TOPIC -> currentPermissions.roomTopic
+ RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions.spaceChild
+ }
+ }
}
enum class RoomPermissionsSection {
- SpaceDetails,
- RoomDetails,
+ ManageMembers,
+ EditDetails,
MessagesAndContent,
- MembershipModeration,
+ ManageSpace
}
enum class SelectableRole : DropdownOption {
@@ -81,5 +102,6 @@ enum class RoomPermissionType {
REDACT_EVENTS,
ROOM_NAME,
ROOM_AVATAR,
- ROOM_TOPIC
+ ROOM_TOPIC,
+ SPACE_MANAGE_ROOMS,
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt
index d64c85f8cfd..2760272d8a4 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt
@@ -19,29 +19,31 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider
get() = sequenceOf(
aChangeRoomPermissionsState(),
+ aChangeRoomPermissionsState(ownPowerLevel = RoomMember.Role.Moderator.powerLevel),
aChangeRoomPermissionsState(hasChanges = true),
aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading),
aChangeRoomPermissionsState(
hasChanges = true,
saveAction = AsyncAction.Failure(IllegalStateException("Failed to save changes"))
),
- aChangeRoomPermissionsState(hasChanges = true, confirmExitAction = AsyncAction.ConfirmingNoParams),
+ aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.ConfirmingCancellation),
+ aChangeRoomPermissionsState(itemsBySection = ChangeRoomPermissionsPresenter.buildItems(isSpace = true)),
)
}
internal fun aChangeRoomPermissionsState(
+ ownPowerLevel: Long = RoomMember.Role.Admin.powerLevel,
currentPermissions: RoomPowerLevelsValues = previewPermissions(),
itemsBySection: Map> = ChangeRoomPermissionsPresenter.buildItems(false),
hasChanges: Boolean = false,
- saveAction: AsyncAction = AsyncAction.Uninitialized,
- confirmExitAction: AsyncAction = AsyncAction.Uninitialized,
+ saveAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (ChangeRoomPermissionsEvent) -> Unit = {},
) = ChangeRoomPermissionsState(
+ ownPowerLevel = ownPowerLevel,
currentPermissions = currentPermissions,
itemsBySection = itemsBySection.toImmutableMap(),
hasChanges = hasChanges,
saveAction = saveAction,
- confirmExitAction = confirmExitAction,
eventSink = eventSink,
)
@@ -53,12 +55,13 @@ private fun previewPermissions(): RoomPowerLevelsValues {
ban = RoomMember.Role.User.powerLevel,
// MessagesAndContent section
redactEvents = RoomMember.Role.Moderator.powerLevel,
- sendEvents = RoomMember.Role.Admin.powerLevel,
+ eventsDefault = RoomMember.Role.Admin.powerLevel,
// RoomDetails section
roomName = RoomMember.Role.Admin.powerLevel,
roomAvatar = RoomMember.Role.Moderator.powerLevel,
roomTopic = RoomMember.Role.User.powerLevel,
// SpaceManagement section
spaceChild = RoomMember.Role.Moderator.powerLevel,
+ stateDefault = RoomMember.Role.Moderator.powerLevel,
)
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt
index 1e88d091c84..529df0d50d6 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt
@@ -18,9 +18,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rolesandpermissions.impl.R
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -29,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
-import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -73,7 +73,8 @@ fun ChangeRoomPermissionsView(
PreferenceDropdown(
title = titleForType(permissionType),
selectedOption = state.selectedRoleForType(permissionType),
- options = SelectableRole.entries.toImmutableList(),
+ options = state.selectableRoles,
+ enabled = state.canChangePermission(permissionType),
onSelectOption = { role ->
state.eventSink(
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(
@@ -91,33 +92,28 @@ fun ChangeRoomPermissionsView(
AsyncActionView(
async = state.saveAction,
- onSuccess = { onComplete(true) },
- onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }
- )
-
- AsyncActionView(
- async = state.confirmExitAction,
- onSuccess = { onComplete(false) },
- confirmationDialog = {
- ConfirmationDialog(
- title = stringResource(R.string.screen_room_change_role_unsaved_changes_title),
- content = stringResource(R.string.screen_room_change_role_unsaved_changes_description),
- submitText = stringResource(CommonStrings.action_save),
- cancelText = stringResource(CommonStrings.action_discard),
- onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
- onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) }
- )
+ onSuccess = { onComplete(it) },
+ confirmationDialog = { confirming ->
+ when (confirming) {
+ is AsyncAction.ConfirmingCancellation -> {
+ SaveChangesDialog(
+ onSaveClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) },
+ onDiscardClick = { state.eventSink(ChangeRoomPermissionsEvent.Exit) },
+ onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) },
+ )
+ }
+ }
},
- onErrorDismiss = {},
+ onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }
)
}
@Composable
private fun titleForSection(section: RoomPermissionsSection): String = when (section) {
- RoomPermissionsSection.SpaceDetails -> stringResource(R.string.screen_room_roles_and_permissions_space_details)
- RoomPermissionsSection.RoomDetails -> stringResource(R.string.screen_room_roles_and_permissions_room_details)
- RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_roles_and_permissions_messages_and_content)
- RoomPermissionsSection.MembershipModeration -> stringResource(R.string.screen_room_roles_and_permissions_member_moderation)
+ RoomPermissionsSection.EditDetails -> stringResource(R.string.screen_room_change_permissions_room_details)
+ RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_change_permissions_messages_and_content)
+ RoomPermissionsSection.ManageMembers -> stringResource(R.string.screen_room_change_permissions_member_moderation)
+ RoomPermissionsSection.ManageSpace -> stringResource(R.string.screen_room_change_permissions_manage_space)
}
@Composable
@@ -130,6 +126,7 @@ private fun titleForType(type: RoomPermissionType): String = when (type) {
RoomPermissionType.ROOM_NAME -> stringResource(R.string.screen_room_change_permissions_room_name)
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
+ RoomPermissionType.SPACE_MANAGE_ROOMS -> stringResource(R.string.screen_room_change_permissions_manage_space_rooms)
}
@PreviewsDayNight
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
index 974f79aa8b2..2f232d5642f 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.model.powerLevelOf
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.services.analytics.api.AnalyticsService
@@ -47,7 +48,7 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@@ -83,18 +84,13 @@ class ChangeRolesPresenter(
val usersWithRole = produceState>(initialValue = persistentListOf()) {
// If the role is admin, we need to include the owners as well since they implicitly have admin role
val owners = if (role == RoomMember.Role.Admin) {
- combine(
- room.usersWithRole(RoomMember.Role.Owner(isCreator = true)),
- room.usersWithRole(RoomMember.Role.Owner(isCreator = false)),
- ) { creators, superAdmins ->
- creators + superAdmins
- }
+ room.usersWithRole { role -> role is RoomMember.Role.Owner }
} else {
- emptyFlow()
+ flowOf(persistentListOf())
}
combine(
owners,
- room.usersWithRole(role),
+ room.usersWithRole { it == role },
) { owners, users ->
owners + users
}.map { members -> members.map { it.toMatrixUser() } }
@@ -138,9 +134,10 @@ class ChangeRolesPresenter(
val roomInfo by room.roomInfoFlow.collectAsState()
fun canChangeMemberRole(userId: UserId): Boolean {
- val currentUserRole = roomInfo.roleOf(room.sessionId)
- val otherUserRole = roomInfo.roleOf(userId)
- return currentUserRole.powerLevel > otherUserRole.powerLevel
+ val currentUserPowerLevel = roomInfo.powerLevelOf(room.sessionId)
+ val otherUserPowerLevel = roomInfo.powerLevelOf(userId)
+ return currentUserPowerLevel > otherUserPowerLevel &&
+ currentUserPowerLevel >= role.powerLevel
}
fun handleEvent(event: ChangeRolesEvent) {
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt
index fcfdd42ffaf..7f7333199f1 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt
@@ -174,8 +174,9 @@ fun ChangeRolesView(
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
- onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) },
- onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }
+ onSaveClick = { state.eventSink(ChangeRolesEvent.Save) },
+ onDiscardClick = { state.eventSink(ChangeRolesEvent.Exit) },
+ onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) },
)
}
is ConfirmingModifyingOwners -> {
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt
index 4469eb8f378..da69ee52a9a 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt
@@ -11,7 +11,6 @@ package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
-import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -20,14 +19,6 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.matrix.api.room.BaseRoom
-import io.element.android.libraries.matrix.api.room.RoomMember
-import io.element.android.libraries.matrix.ui.model.roleOf
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.launch
@ContributesNode(RoomScope::class)
@AssistedInject
@@ -35,7 +26,6 @@ class RolesAndPermissionsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val presenter: RolesAndPermissionsPresenter,
- private val room: BaseRoom,
) : Node(buildContext, plugins = plugins), RolesAndPermissionsNavigator {
interface Callback : Plugin, RolesAndPermissionsNavigator {
override fun openAdminList()
@@ -54,22 +44,6 @@ class RolesAndPermissionsNode(
}
}
- override fun onBuilt() {
- super.onBuilt()
-
- // If the user is not an admin anymore, exit this section since they won't have permissions to use it
- lifecycleScope.launch {
- room.roomInfoFlow
- .filter { info ->
- val role = info.roleOf(room.sessionId)
- role != RoomMember.Role.Admin && role !is RoomMember.Role.Owner
- }
- .take(1)
- .onEach { navigateUp() }
- .collect()
- }
- }
-
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt
index 2ade971a663..dd3a59b99ef 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt
@@ -22,14 +22,13 @@ 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.core.coroutine.CoroutineDispatchers
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
-import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
-import io.element.android.libraries.matrix.api.room.activeRoomMembers
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
+import io.element.android.libraries.matrix.api.room.powerlevels.userCountWithRole
import io.element.android.libraries.matrix.ui.model.roleOf
import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -43,33 +42,24 @@ class RolesAndPermissionsPresenter(
override fun present(): RolesAndPermissionsState {
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState()
- val roomMembers by room.membersStateFlow.collectAsState()
- // Get the list of active room members (joined or invited), in order to filter members present in the power
- // level state Event.
- val activeRoomMemberIds by remember {
- derivedStateOf {
- roomMembers.activeRoomMembers().map { it.userId }
- }
- }
val moderatorCount by remember {
- derivedStateOf {
- roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Moderator)
- }
- }
+ room.userCountWithRole { role -> role is RoomMember.Role.Moderator }
+ }.collectAsState(null)
+
val adminCount by remember {
+ room.userCountWithRole { role -> role is RoomMember.Role.Admin || role is RoomMember.Role.Owner }
+ }.collectAsState(null)
+
+ val availableDemoteActions by remember {
derivedStateOf {
- val admins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Admin)
- val ownersCount = if (roomInfo.privilegedCreatorRole) {
- val superAdmins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = false))
- val creators = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = true))
- superAdmins + creators
- } else {
- 0
+ val currentRole = roomInfo.roleOf(room.sessionId)
+ when (currentRole) {
+ is RoomMember.Role.Admin -> persistentListOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember)
+ is RoomMember.Role.Moderator -> persistentListOf(SelfDemoteAction.ToMember)
+ else -> persistentListOf()
}
- admins + ownersCount
}
}
- val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } }
val changeOwnRoleAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val resetPermissionsAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
@@ -98,7 +88,7 @@ class RolesAndPermissionsPresenter(
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
adminCount = adminCount,
moderatorCount = moderatorCount,
- canDemoteSelf = canDemoteSelf.value,
+ availableSelfDemoteActions = availableDemoteActions,
changeOwnRoleAction = changeOwnRoleAction.value,
resetPermissionsAction = resetPermissionsAction.value,
eventSink = ::handleEvent,
@@ -122,8 +112,4 @@ class RolesAndPermissionsPresenter(
room.resetPowerLevels()
}
}
-
- private fun RoomInfo.userCountWithRole(userIds: List, role: RoomMember.Role): Int {
- return usersWithRole(role).filter { it in userIds }.size
- }
}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt
index 3fc94f99e9f..626ad3b6999 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt
@@ -8,14 +8,24 @@
package io.element.android.features.rolesandpermissions.impl.root
+import io.element.android.features.rolesandpermissions.impl.R
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.room.RoomMember
+import kotlinx.collections.immutable.ImmutableList
data class RolesAndPermissionsState(
val roomSupportsOwnerRole: Boolean,
- val adminCount: Int,
- val moderatorCount: Int,
- val canDemoteSelf: Boolean,
+ val adminCount: Int?,
+ val moderatorCount: Int?,
+ val availableSelfDemoteActions: ImmutableList,
val changeOwnRoleAction: AsyncAction,
val resetPermissionsAction: AsyncAction,
val eventSink: (RolesAndPermissionsEvents) -> Unit,
-)
+) {
+ val canSelfDemote = availableSelfDemoteActions.isNotEmpty()
+}
+
+enum class SelfDemoteAction(val role: RoomMember.Role, val titleRes: Int) {
+ ToModerator(RoomMember.Role.Moderator, R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator),
+ ToMember(RoomMember.Role.User, R.string.screen_room_roles_and_permissions_change_role_demote_to_member)
+}
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt
index 23448c03514..45bd72db190 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt
@@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
+import kotlinx.collections.immutable.toImmutableList
class RolesAndPermissionsStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -46,7 +47,7 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider = listOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember),
changeOwnRoleAction: AsyncAction = AsyncAction.Uninitialized,
resetPermissionsAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
roomSupportsOwnerRole = roomSupportsOwners,
adminCount = adminCount,
- canDemoteSelf = canDemoteSelf,
+ availableSelfDemoteActions = availableSelfDemoteActions.toImmutableList(),
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
resetPermissionsAction = resetPermissionsAction,
diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt
index 189ad83a5c8..269fdee6641 100644
--- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt
+++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt
@@ -39,8 +39,8 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
-import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
@Composable
fun RolesAndPermissionsView(
@@ -63,16 +63,20 @@ fun RolesAndPermissionsView(
ListItem(
headlineContent = { Text(adminsTitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
- trailingContent = ListItemContent.Text("${state.adminCount}"),
+ trailingContent = state.adminCount?.let { adminCount ->
+ ListItemContent.Text("$adminCount")
+ },
onClick = { rolesAndPermissionsNavigator.openAdminList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
- trailingContent = ListItemContent.Text("${state.moderatorCount}"),
+ trailingContent = state.moderatorCount?.let { moderationCount ->
+ ListItemContent.Text("$moderationCount")
+ },
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
)
- if (state.canDemoteSelf) {
+ if (state.canSelfDemote) {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
@@ -113,6 +117,7 @@ fun RolesAndPermissionsView(
when (state.changeOwnRoleAction) {
is AsyncAction.Confirming -> {
ChangeOwnRoleBottomSheet(
+ availableDemoteActions = state.availableSelfDemoteActions,
eventSink = state.eventSink,
)
}
@@ -132,6 +137,7 @@ fun RolesAndPermissionsView(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChangeOwnRoleBottomSheet(
+ availableDemoteActions: ImmutableList,
eventSink: (RolesAndPermissionsEvents) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
@@ -160,24 +166,17 @@ private fun ChangeOwnRoleBottomSheet(
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
- ListItem(
- headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
- onClick = {
- sheetState.hide(coroutineScope) {
- eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
- }
- },
- style = ListItemStyle.Destructive,
- )
- ListItem(
- headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
- onClick = {
- sheetState.hide(coroutineScope) {
- eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User))
- }
- },
- style = ListItemStyle.Destructive,
- )
+ for (demoteAction in availableDemoteActions) {
+ ListItem(
+ headlineContent = { Text(stringResource(demoteAction.titleRes)) },
+ onClick = {
+ sheetState.hide(coroutineScope) {
+ eventSink(RolesAndPermissionsEvents.DemoteSelfTo(demoteAction.role))
+ }
+ },
+ style = ListItemStyle.Destructive,
+ )
+ }
ListItem(
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
onClick = ::dismiss,
diff --git a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml
index 3df8e0afbbf..87936de3a71 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml
@@ -2,9 +2,12 @@
"Správce"
"Vykázat lidi"
+ "Změnit nastavení"
"Odstranit zprávy"
"Člen"
"Pozvat přátele"
+ "Správa prostoru"
+ "Spravovat místnosti"
"Spravovat členy"
"Zprávy a obsah"
"Moderátor"
@@ -14,6 +17,7 @@
"Změnit název místnosti"
"Změnit téma místnosti"
"Odeslat zprávy"
+ "Oprávnění"
"Upravit správce"
"Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy."
"Přidat správce?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml
index 4a0b5d179d4..31aee3436e9 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml
@@ -1,17 +1,23 @@
- "Kun admins"
+ "Administrator"
"Spær brugere"
+ "Skift indstillinger"
"Fjern beskeder"
- "Invitér personer og acceptér anmodninger om at deltage"
+ "Medlem"
+ "Invitér andre"
+ "Administrér gruppe"
+ "Administrer rum"
+ "Administrer medlemmer"
"Beskeder og indhold"
- "Admins og moderatorer"
- "Fjern personer og afvis anmodninger om at deltage"
+ "Moderator"
+ "Fjern personer"
"Skift rummets avatar"
- "Rediger rum"
+ "Redigér detaljer"
"Skift rummets navn"
"Skift emne for rummet"
"Send beskeder"
+ "Tilladelser"
"Redigér admins"
"Du kan ikke fortryde denne handling. Du forfremmer brugeren til at have samme magtniveau som dig."
"Tilføj Admin?"
@@ -32,6 +38,12 @@
"Du har ændringer, der ikke er gemt."
"Gem ændringer?"
"Der er ingen spærrede brugere i dette rum."
+
+ - "%1$d Spærret"
+ - "%1$d Spærret"
+
+ "Tjek stavningen eller prøv en ny søgning"
+ "Ingen resultater for \"%1$s\""
- "%1$d person"
- "%1$d personer"
@@ -43,8 +55,13 @@
"Fjern brugerens spærring fra rummet"
"Spærret"
"Medlemmer"
- "Kun admins"
- "Admins og moderatorer"
+
+ - "%1$d Inviteret"
+ - "%1$d Inviteret"
+
+ "Afventer"
+ "Administrator"
+ "Moderator"
"Ejeren"
"Medlemmer af rummet"
"Ophæver spærring af %1$s"
@@ -57,10 +74,12 @@
"Beskeder og indhold"
"Moderatorer"
"Ejere"
+ "Tilladelser"
"Nulstil tilladelser"
"Når du nulstiller tilladelserne, mister du de nuværende indstillinger."
"Nulstil tilladelser?"
"Roller"
"Detaljer om rummet"
+ "Detaljer om gruppe"
"Roller og tilladelser"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml
index 2767781d2ba..f51f96672b8 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml
@@ -1,17 +1,23 @@
- "Nur Admins"
+ "Admin"
"Mitglieder sperren"
+ "Einstellungen ändern"
"Nachrichten entfernen"
- "Personen einladen und Beitrittsanfragen annehmen"
+ "Mitglied"
+ "Mitglieder hinzufügen"
+ "Space konfigurieren"
+ "Chats und Gruppen konfigurieren"
+ "Mitglieder verwalten"
"Nachrichten senden & löschen"
- "Admins und Moderatoren"
- "Personen entfernen und Beitrittsanfragen ablehnen"
+ "Moderator"
+ "Mitglieder entfernen"
"Avatar ändern"
"Chat bearbeiten"
"Chat-Namen ändern"
"Chat Thema ändern"
"Nachrichten senden"
+ "Berechtigungen"
"Admins bearbeiten"
"Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, die du auch hast."
"Als Admin hinzufügen?"
@@ -32,6 +38,12 @@
"Du hast nicht gespeicherte Änderungen."
"Änderungen speichern?"
"Es gibt keine gesperrten Nutzer."
+
+ - "%1$d gesperrt"
+ - "%1$d gesperrt"
+
+ "Überprüfe die Schreibweise oder versuch\'s mit einer neuen Suche"
+ "Keine Ergebnisse für „%1$s“"
- "%1$d Person"
- "%1$d Personen"
@@ -43,8 +55,13 @@
"Sperre für diesen Chat aufheben"
"Gesperrt"
"Mitglieder"
- "Nur Admins"
- "Admins und Moderatoren"
+
+ - "%1$d eingeladen"
+ - "%1$d eingeladen"
+
+ "Ausstehend"
+ "Admin"
+ "Moderator"
"Eigentümer"
"Mitglieder"
"%1$s wird entsperrt."
@@ -57,10 +74,12 @@
"Nachrichten senden & löschen"
"Moderatoren"
"Eigentümer"
- "Rollen und Berechtigungen zurücksetzen"
+ "Berechtigungen"
+ "Berechtigungen zurücksetzen"
"Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen."
"Berechtigungen zurücksetzen?"
"Rollen"
"Chat-Details anpassen"
+ "Details zum Space"
"Rollen und Berechtigungen"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml
index 7924e7ce7ba..1c1f490580d 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml
@@ -2,9 +2,12 @@
"Peakasutajad"
"Suhtluskeelu seadmine"
+ "Muuda seadistusi"
"Eemalda sõnumid"
"Liikmed"
"Osalejate kutsumine"
+ "Halda kogukonda"
+ "Halda jututuba"
"Liikmete haldus"
"Sõnumid ja sisu"
"Moderaatorid"
@@ -14,6 +17,7 @@
"Jututoa nime muutmine"
"Jututoa teema muutmine"
"Sõnumite saatmine"
+ "Õigused"
"Muuda peakasutajaid"
"Kuna sa annad teisele kasutajale sinu õigustega võrreldes samad õigused, siis sa ei saa seda muudatust hiljem tagasi pöörata."
"Lisame peakasutaja?"
@@ -34,6 +38,10 @@
"Sul on salvestamata muudatusi"
"Kas salvestame muudatused?"
"Suhtluskeeluga kasutajaid pole"
+
+ - "%1$d suhtluskeeluga kasutaja"
+ - "%1$d suhtluskeeluga kasutajat"
+
"Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti"
"Otsingul „%1$s“ pole tulemusi"
@@ -47,6 +55,10 @@
"Eemalda suhtluskeeld jututoas"
"Suhtluskeeluga kasutajad"
"Liikmed"
+
+ - "%1$d saatis kutse"
+ - "%1$d saatis kutse"
+
"Ootel"
"Peakasutajad"
"Moderaatorid"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml
index 1322089dc8a..29331bcd136 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml
@@ -2,9 +2,12 @@
"Ylläpitäjä"
"Porttikieltojen antaminen"
+ "Asetusten muuttaminen"
"Viestien poistaminen"
"Jäsen"
"Ihmisten kutsuminen ja liittymispyyntöjen hyväksyminen"
+ "Tilan hallitseminen"
+ "Huoneiden hallitseminen"
"Jäsenien hallinta"
"Viestit ja sisältö"
"Valvoja"
@@ -14,6 +17,7 @@
"Huoneen nimen vaihtaminen"
"Huoneen aiheen vaihtaminen"
"Viestien lähettäminen"
+ "Oikeudet"
"Muokkaa ylläpitäjiä"
"Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä."
"Lisätäänkö ylläpitäjä?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml
index f44072366df..874b6710cd9 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml
@@ -2,9 +2,12 @@
"Administrateurs"
"Bannir des participants"
+ "Changer les paramètres"
"Supprimer des messages"
"Membre"
"Inviter des personnes"
+ "Gérer l’espace"
+ "Gérer les salons"
"Gérer les membres"
"Messages et contenus"
"Modérateurs"
@@ -14,6 +17,7 @@
"Changer le nom du salon"
"Changer le sujet du salon"
"Envoyer des messages"
+ "Autorisations"
"Modifier les administrateurs"
"Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir l’utilisateur pour qu’il ait le même niveau que vous."
"Ajouter un administrateur ?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 00000000000..7f24535124e
--- /dev/null
+++ b/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,88 @@
+
+
+ "Administrator"
+ "Zabrana pristupa osobama"
+ "Promijeni postavke"
+ "Uklanjanje poruka"
+ "Član"
+ "Pozivanje osoba"
+ "Upravljaj prostorom"
+ "Upravljaj sobama"
+ "Upravljanje članovima"
+ "Poruke i sadržaj"
+ "Moderator"
+ "Uklanjanje osoba"
+ "Promjena avatara"
+ "Uredi pojedinosti"
+ "Promjena imena"
+ "Promjena teme"
+ "Slanje poruka"
+ "Dopuštenja"
+ "Uredi administratore"
+ "Nećete moći poništiti ovu radnju. Postavit ćete da korisnik ima isti položaj kao i vi."
+ "Dodati administratora?"
+ "Nećete moći poništiti ovu radnju. Prenosite vlasništvo na odabrane korisnike. Nakon što odete, to će biti trajno."
+ "Želite li prenijeti vlasništvo?"
+ "Degradiraj"
+ "Nećete moći poništiti ovu promjenu jer sami sebe degradirate. Ako ste posljednji privilegirani korisnik u sobi, nećete moći ponovno dobiti privilegije."
+ "Želite li se degradirati?"
+ "%1$s (na čekanju)"
+ "(na čekanju)"
+ "Administratori automatski imaju moderatorske ovlasti"
+ "Vlasnici automatski imaju administratorske ovlasti."
+ "Uredi moderatore"
+ "Odaberi vlasnike"
+ "Administratori"
+ "Moderatori"
+ "Članovi"
+ "Niste spremili sve promjene."
+ "Želite li spremiti promjene?"
+ "Nema zabranjenih korisnika."
+
+ - "%1$d zabranjen"
+ - "%1$d zabranjena"
+ - "%1$d zabranjenih"
+
+ "Provjerite pravopis ili pokušajte s novim pretraživanjem"
+ "Nema rezultata za “%1$s”"
+
+ - "%1$d osoba"
+ - "%1$d osobe"
+ - "%1$d ljudi"
+
+ "Zabrani korisnika"
+ "Samo ukloni člana"
+ "Poništi zabranu"
+ "Moći će se ponovno pridružiti ovoj sobi ako budu pozvani."
+ "Poništi zabranu pristupa korisniku"
+ "Zabranjeni"
+ "Članovi"
+
+ - "%1$d pozvan"
+ - "%1$d pozvana"
+ - "%1$d pozvanih"
+
+ "Na čekanju"
+ "Administrator"
+ "Moderator"
+ "Vlasnik"
+ "Članovi sobe"
+ "Uklanja se zabrana korisniku %1$s"
+ "Administratori"
+ "Administratori i vlasnici"
+ "Promijeni moju ulogu"
+ "Degradiraj u člana"
+ "Degradiraj u moderatora"
+ "Moderiranje članova"
+ "Poruke i sadržaj"
+ "Moderatori"
+ "Vlasnici"
+ "Dopuštenja"
+ "Poništi dopuštenja"
+ "Nakon što poništite dopuštenja, izgubit ćete trenutačne postavke."
+ "Želite li poništiti dopuštenja?"
+ "Uloge"
+ "Pojedinosti o sobi"
+ "Pojedinosti o prostoru"
+ "Uloge i dopuštenja"
+
diff --git a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml
index 77e6e3b389c..4160039a0a2 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml
@@ -2,9 +2,12 @@
"Adminisztrátor"
"Emberek kitiltása"
+ "Beállítások módosítása"
"Üzenetek eltávolítása"
"Tag"
"Emberek meghívása"
+ "Tér kezelése"
+ "Szobák kezelése"
"Tagok kezelése"
"Üzenetek és tartalom"
"Moderátor"
@@ -14,6 +17,7 @@
"Szoba nevének módosítása"
"Szoba témájának módosítása"
"Üzenetek küldése"
+ "Jogosultságok"
"Adminisztrátorok szerkesztése"
"Ezt a műveletet nem fogja tudja visszavonni. Ugyanarra a szintre lépteti elő a felhasználót, mint amellyel Ön is rendelkezik."
"Adminisztrátor hozzáadása?"
@@ -34,6 +38,8 @@
"Mentetlen módosításai vannak."
"Menti a módosításokat?"
"Ebben a szobában nincsenek kitiltott felhasználók."
+ "Ellenőrizze a helyesírást, vagy próbáljon meg egy új keresést"
+ "Nincs találat a következőre: „%1$s\""
- "%1$d személy"
- "%1$d személy"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml
index 2b439a6c616..b1dea12151f 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml
@@ -2,9 +2,12 @@
"Amministratore"
"Escludi membri"
+ "Modifica impostazioni"
"Rimuovi messaggi"
"Membro"
"Invita persone"
+ "Gestire lo spazio"
+ "Gestisci le stanze"
"Gestisci membri"
"Messaggi e contenuti"
"Moderatore"
@@ -14,6 +17,7 @@
"Cambia il nome della stanza"
"Cambiare l\'argomento della stanza"
"Inviare messaggi"
+ "Autorizzazioni"
"Modifica amministratori"
"Non potrai annullare questa azione. Stai promuovendo l\'utente al tuo stesso livello di potere."
"Aggiungi amministratore?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
index 65664c187ae..41f475012f9 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml
@@ -1,14 +1,16 @@
- "Kun for administratorer"
+ "Admin"
"Forby folk"
"Fjern meldinger"
- "Inviter folk og godta forespørsler om å bli med"
+ "Medlem"
+ "Inviter folk"
+ "Administrer medlemmer"
"Meldinger og innhold"
- "Administratorer og moderatorer"
- "Fjern folk og avslå forespørsler om å bli med"
+ "Moderator"
+ "Fjern folk"
"Endre romavatar"
- "Rediger rom"
+ "Rediger detaljer"
"Endre romnavn"
"Endre temaet til rommet"
"Send meldinger"
@@ -31,7 +33,7 @@
"Medlemmer"
"Du har endringer som ikke er lagret."
"Lagre endringer?"
- "Det er ingen utestengte brukere i dette rommet."
+ "Det er ingen utestengte brukere."
- "%1$d person"
- "%1$d personer"
@@ -43,8 +45,8 @@
"Fjern utestengelsen fra rommet"
"Utestengt"
"Medlemmer"
- "Kun for administratorer"
- "Administratorer og moderatorer"
+ "Admin"
+ "Moderator"
"Eier"
"Medlemmer av rommet"
"Oppheve utestengelsen av %1$s"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml
index 886d3c541d0..43fa8d83f20 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml
@@ -2,9 +2,12 @@
"Administradores"
"Banir pessoas"
+ "Alterar configurações"
"Remover mensagens"
"Membro"
"Convidar pessoas"
+ "Gerenciar espaço"
+ "Gerenciar salas"
"Gerenciar membros"
"Mensagens e conteúdo"
"Moderador"
@@ -14,6 +17,7 @@
"Alterar nome da sala"
"Alterar tópico da sala"
"Enviar mensagens"
+ "Permissões"
"Editar administradores"
"Você não poderá desfazer essa ação. Você está promovendo o usuário a ter o mesmo nível de poder que você."
"Adicionar administrador?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml
index c2b248f2649..ac9ec710a2e 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml
@@ -1,17 +1,23 @@
- "Doar administratori"
+ "Administrator"
"Interziceți persoane"
+ "Modificați setările"
"Ștergeți mesajele"
- "Invitați persoane și acceptați cereri de alaturare"
+ "Membru"
+ "Invitați persoane"
+ "Gestionați spațiul"
+ "Gestionați camerele"
+ "Gestionați membrii"
"Mesaje și conținut"
- "Administratori și moderatori"
- "Îndepărtați persoane și refuzați cereri de alăturare"
+ "Moderator"
+ "Îndepărtați persoane"
"Schimbați avatarul camerei"
- "Editați camera"
+ "Editați detaliile"
"Schimbă numele camerei"
"Schimbați subiectul camerei"
"Trimiteți mesaje"
+ "Permisiuni"
"Editați administratorii"
"Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune."
"Adăugați administrator?"
@@ -31,9 +37,17 @@
"Membri"
"Aveți modificări nesalvate."
"Salvați modificările?"
- "Nu există utilizatori interziși în această cameră."
+ "Nu există utilizatori interziși."
+
+ - "%1$d Interzis"
+ - "%1$d Interziși"
+ - "%1$d Interziși"
+
+ "Verificați ortografia sau încercați o căutare nouă"
+ "Niciun rezultat pentru “%1$s”"
- - "o persoană"
+ - "%1$d persoană"
+ - "%1$d persoane"
- "%1$d persoane"
"Îndepărtați și interziceți membrul"
@@ -43,8 +57,14 @@
"Revocati excluderea din camera"
"Excluși"
"Membri"
- "Doar administratori"
- "Administratori și moderatori"
+
+ - "%1$d Invitat"
+ - "%1$d Invitați"
+ - "%1$d Invitați"
+
+ "În așteptare"
+ "Administrator"
+ "Moderator"
"Proprietar"
"Membrii camerei"
"Se anulează interzicerea lui %1$s"
@@ -57,10 +77,12 @@
"Mesaje și conținut"
"Moderatori"
"Proprietari"
+ "Permisiuni"
"Resetați permisiunile"
"După ce resetați permisiunile, veți pierde setările curente."
"Resetați permisiunile?"
"Roluri"
"Detaliile camerei"
+ "Detalii spațiu"
"Roluri și permisiuni"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml
index f54b984ea30..d922e585506 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml
@@ -2,9 +2,12 @@
"Только администраторы"
"Блокировать людей могут"
+ "Изменить настройки"
"Удалить сообщения"
"Участник"
"Пригласить людей"
+ "Управление пространством"
+ "Управление комнатами"
"Список участников"
"Сообщения и содержание"
"Модератор"
@@ -14,6 +17,7 @@
"Менять название комнаты могут"
"Менять тему комнаты могут"
"Отправлять сообщения могут"
+ "Разрешения"
"Редактировать роль администраторов"
"Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему."
"Добавить администратора?"
diff --git a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml
index a707b48bc55..d159fb03f93 100644
--- a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml
+++ b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml
@@ -1,17 +1,23 @@
- "Iba správcovia"
+ "Správca"
"Zakázať ľudí"
+ "Zmeniť nastavenia"
"Odstrániť správy"
- "Pozvite ľudí a prijmite žiadosti o pripojenie"
+ "Člen"
+ "Pozvať ľudí"
+ "Spravovať priestor"
+ "Spravovať miestnosti"
+ "Spravovať členov"
"Správy a obsah"
- "Správcovia a moderátori"
- "Odstrániť ľudí a odmietnuť žiadosti o pripojenie"
+ "Moderátor"
+ "Odstrániť ľudí"
"Zmeniť obrázok miestnosti"
- "Upraviť miestnosť"
+ "Upraviť podrobnosti"
"Zmeniť názov miestnosti"
"Zmeniť tému miestnosti"
"Odoslať správy"
+ "Povolenia"
"Upraviť správcov"
"Túto akciu nebudete môcť vrátiť späť. Zvyšujete úroveň používateľa na rovnakú úroveň výkonu ako máte vy."
"Pridať správcu?"
@@ -32,6 +38,13 @@
"Máte neuložené zmeny."
"Uložiť zmeny?"
"Neexistujú žiadni zablokovaní používatelia."
+
+