diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 17e1fa0f1c..c16310cf43 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -34,6 +34,13 @@
"/^org.jetbrains.kotlinx:kotlinx-datetime/",
],
},
+ {
+ // Keep Guava on the Android variant and ignore jre-only upgrades.
+ "matchPackageNames": [
+ "com.google.guava:guava",
+ ],
+ "allowedVersions": "/-android$/",
+ },
{
// Limit PostHog Android upgrade to one PR per month, the first day of the month
"matchPackageNames": [
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c60a89e2f6..dd5524c7e3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -74,7 +74,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-debug
path: |
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index aa00b74c44..956655b262 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -79,7 +79,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-enterprise-debug
path: |
diff --git a/.github/workflows/fork-pr-notice.yml b/.github/workflows/fork-pr-notice.yml
index af3e4a3006..3e67d97eac 100644
--- a/.github/workflows/fork-pr-notice.yml
+++ b/.github/workflows/fork-pr-notice.yml
@@ -20,7 +20,7 @@ jobs:
if: github.event.pull_request.base.repo.full_name != github.event.pull_request.head.repo.full_name
steps:
- name: Add auto-generated commit warning
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
github.rest.issues.createComment({
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 55a300dd88..8de21b3ef1 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -36,7 +36,7 @@ jobs:
mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages
- uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
+ uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots
diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml
index 8903ed0e57..9af5c6bc1d 100644
--- a/.github/workflows/maestro-local.yml
+++ b/.github/workflows/maestro-local.yml
@@ -60,7 +60,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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-apk-maestro
path: |
@@ -119,7 +119,7 @@ jobs:
script: |
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk
- name: Upload test results
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-results
path: |
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 314929d801..371c11b3ff 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -58,7 +58,7 @@ jobs:
- name: ✅ Upload kover report
if: always()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: kover-results
path: |
@@ -92,7 +92,7 @@ jobs:
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html
diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml
index 5349a678bc..6efeff17be 100644
--- a/.github/workflows/post-release.yml
+++ b/.github/workflows/post-release.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Trigger pipeline
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.ENTERPRISE_ACTIONS_TOKEN }}
script: |
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index d90cf07e50..f0c8fd1e6f 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -17,7 +17,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
- uses: tspascoal/get-user-teams-membership@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
+ uses: tspascoal/get-user-teams-membership@818140d631d5f29f26b151afbe4179f87d9ceb5e # v4.0.1
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
@@ -41,7 +41,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
github.rest.issues.addLabels({
@@ -63,7 +63,7 @@ jobs:
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
github.rest.issues.createComment({
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 9a191ba15b..ceaa86016a 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -120,7 +120,7 @@ jobs:
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: konsist-report
path: |
@@ -199,7 +199,7 @@ jobs:
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: linting-report
path: |
@@ -240,7 +240,7 @@ jobs:
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: detekt-report
path: |
@@ -281,7 +281,7 @@ jobs:
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
- name: Upload reports
if: always()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ktlint-report
path: |
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- - uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
+ - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
upload_reports:
name: Project Check Suite
diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml
index 0f4c8ee581..4b70cffe61 100644
--- a/.github/workflows/recordScreenshots.yml
+++ b/.github/workflows/recordScreenshots.yml
@@ -17,6 +17,7 @@ jobs:
permissions:
# Need write permissions on PRs to remove the label "Record-Screenshots"
pull-requests: write
+ contents: write
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 73cba6c8f7..eece5ab0d4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -57,7 +57,7 @@ jobs:
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-app-gplay-bundle-unsigned
path: |
@@ -95,7 +95,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-enterprise-app-gplay-bundle-unsigned
path: |
@@ -139,7 +139,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: elementx-app-fdroid-apks-unsigned
path: |
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index f45a926814..6de0f3b0df 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -40,7 +40,7 @@ jobs:
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
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 7f9dbdee0d..9f7a67cc22 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -27,7 +27,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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: Sync SAS Strings
title: Sync SAS Strings
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0ce66df478..842c8113f4 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -77,7 +77,7 @@ jobs:
- name: 🚫 Upload kover failed coverage reports
if: failure()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: kover-error-report
path: |
@@ -89,7 +89,7 @@ jobs:
- name: 🚫 Upload test results on error
if: failure()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: tests-and-screenshot-tests-results
path: |
@@ -108,7 +108,7 @@ jobs:
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
- uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/triage-incoming.yml b/.github/workflows/triage-incoming.yml
index 8e8d03c9c4..b93ea81403 100644
--- a/.github/workflows/triage-incoming.yml
+++ b/.github/workflows/triage-incoming.yml
@@ -10,7 +10,7 @@ jobs:
triage-new-issues:
runs-on: ubuntu-latest
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
with:
project-url: https://github.com/orgs/element-hq/projects/91
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml
index 3ec20f332b..0b587369ed 100644
--- a/.github/workflows/triage-labelled.yml
+++ b/.github/workflows/triage-labelled.yml
@@ -14,7 +14,7 @@ jobs:
if: >
github.repository == 'element-hq/element-x-android'
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
with:
project-url: https://github.com/orgs/element-hq/projects/43
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -23,7 +23,7 @@ jobs:
name: Move triaged needs info issues on board
runs-on: ubuntu-latest
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
id: addItem
with:
project-url: https://github.com/orgs/element-hq/projects/91
@@ -47,7 +47,7 @@ jobs:
if: >
contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
with:
project-url: https://github.com/orgs/element-hq/projects/73
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -58,7 +58,7 @@ jobs:
if: >
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
with:
project-url: https://github.com/orgs/element-hq/projects/57
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -70,7 +70,7 @@ jobs:
contains(github.event.issue.labels.*.name, 'Team: QA') ||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
with:
project-url: https://github.com/orgs/element-hq/projects/69
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
@@ -81,7 +81,7 @@ jobs:
if: >
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
steps:
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2
with:
project-url: https://github.com/orgs/element-hq/projects/89
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 76f6344777..f393d5cdd1 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,7 @@
-
+
+
diff --git a/.maestro/tests/roomList/createAndDeleteDM.yaml b/.maestro/tests/roomList/createAndDeleteDM.yaml
index a6279151ea..eed576c04f 100644
--- a/.maestro/tests/roomList/createAndDeleteDM.yaml
+++ b/.maestro/tests/roomList/createAndDeleteDM.yaml
@@ -7,7 +7,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn:
text: ${MAESTRO_INVITEE1_MXID}
index: 1
-- tapOn: "Send invite"
+- tapOn: "Continue"
- takeScreenshot: build/maestro/330-createAndDeleteDM
- tapOn: "maestroelement2"
- scroll
diff --git a/.maestro/tests/roomList/createAndDeleteRoom.yaml b/.maestro/tests/roomList/createAndDeleteRoom.yaml
index b53066ccd5..adf9d7cf29 100644
--- a/.maestro/tests/roomList/createAndDeleteRoom.yaml
+++ b/.maestro/tests/roomList/createAndDeleteRoom.yaml
@@ -24,8 +24,16 @@ appId: ${MAESTRO_APP_ID}
text: ${MAESTRO_INVITEE2_MXID}
index: 1
- tapOn: "Invite"
+- runFlow:
+ when:
+ visible: 'Invite new contact to this room?'
+ commands:
+ - tapOn:
+ id: "confirm_invite_unknown"
+# Close the keyboard if it's still open
+- tapOn: "Back"
+# Go back to the room details screen
- tapOn: "Back"
-- tapOn: "aRoomName"
- scrollUntilVisible:
direction: DOWN
element:
diff --git a/CHANGES.md b/CHANGES.md
index 68848d491f..9775c2fea5 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,213 @@
+Changes in Element X v26.05.2
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Remove SignInWithClassic FeatureFlag to enable the feature. by @bmarty in https://github.com/element-hq/element-x-android/pull/6698
+* Create a new room when inviting people in a DM by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6756
+* Remove LiveLocationSharing feature flag by @ganfra in https://github.com/element-hq/element-x-android/pull/6811
+### 🙌 Improvements
+* Disable biometric unlock when we disable pin code unlock by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6781
+### 🐛 Bugfixes
+* Fix room list duplicate-detection telemetry crashing before it can report by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/6791
+* Only load full media on media viewer when it's the visible item by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6794
+* Attempt to fix room list item duplicates at midnight by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6793
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6798
+### 🧱 Build
+* Fix Maestro again after changes to the invite flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6796
+* Renovate: Keep Guava on the Android variant and ignore jre-only upgrades by @bmarty in https://github.com/element-hq/element-x-android/pull/6776
+### Dependency upgrades
+* Update dependency androidx.compose:compose-bom to v2026.05.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6784
+* Update dependency io.sentry:sentry-android to v8.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6787
+* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6790
+* Update camera to v1.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6783
+* Update dependency androidx.webkit:webkit to v1.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6786
+* Update dependency com.google.firebase:firebase-bom to v34.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6789
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.18 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6805
+### Others
+* Add MIDI playback by @cizra in https://github.com/element-hq/element-x-android/pull/6770
+* Show error message when using "Sign in with QR code" with a QR from a device that is also not signed in by @hughns in https://github.com/element-hq/element-x-android/pull/6802
+
+## New Contributors
+* @jennaharris7 made their first contribution in https://github.com/element-hq/element-x-android/pull/6791
+* @cizra made their first contribution in https://github.com/element-hq/element-x-android/pull/6770
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.05.1...v26.05.2
+
+Changes in Element X v26.05.1
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Make Element Call screen work edge-to-edge by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6634
+### 🙌 Improvements
+* Stop removing the `logs` dir when clearing cache by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6765
+* Adapt to new DM definition changes in the SDK by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6748
+* feat: Update call started timeline item + declined support by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6649
+### 🐛 Bugfixes
+* Improve pin code UX by @bmarty in https://github.com/element-hq/element-x-android/pull/6744
+* Use just the other user's avatar for DM details by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6738
+* Improve `FetchPushForegroundService`'s reliability by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6757
+* Prevent user from starting Live Location Sharing in thread by @bmarty in https://github.com/element-hq/element-x-android/pull/6767
+* Fix media playback from the timeline broken when exiting a thread by @bmarty in https://github.com/element-hq/element-x-android/pull/6771
+* Pin code: remove the key if there is no pin code by @bmarty in https://github.com/element-hq/element-x-android/pull/6780
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6761
+### 🚧 In development 🚧
+* Feature : share live location by @ganfra in https://github.com/element-hq/element-x-android/pull/6741
+### Dependency upgrades
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6746
+* Update actions/add-to-project action to v2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6758
+* Update dependency io.github.sergio-sastre.ComposablePreviewScanner:android to v0.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6759
+* Update dependency io.element.android:element-call-embedded to v0.19.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6766
+* Update metro to v1 (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6720
+* Update tspascoal/get-user-teams-membership action to v4.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6750
+* Update plugin sonarqube to v7.3.0.8198 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6743
+* Update plugin dependencycheck to v12.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6760
+* Update dependency com.google.guava:guava to v33.6.0-android by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6646
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6779
+### Others
+* Render media captions formatting in the media viewer by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6729
+* Reduce FeatureFlag `Knock` effect on room creation and room edition forms by @bmarty in https://github.com/element-hq/element-x-android/pull/6768
+* Use the right analytics span as a parent in `checkNetworkConnection` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6751
+* Add missing strings `theme.black` by @bmarty in https://github.com/element-hq/element-x-android/pull/6772
+* Map back button in web view to esc (revive fixed version of: https://github.com/element-hq/element-x-android/pull/6724) by @toger5 in https://github.com/element-hq/element-x-android/pull/6725
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.05.0...v26.05.1
+
+Changes in Element X v26.05.0
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Add flag for automatic back pagination feature by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6637
+* Promote "history sharing on invite" out of developer options by @richvdh in https://github.com/element-hq/element-x-android/pull/6647
+* Remove RoomDirectorySearch feature flag — always enable the feature by @Copilot in https://github.com/element-hq/element-x-android/pull/6736
+### 🙌 Improvements
+* Change native back button behavior in EC view (close settings in EC with os native back) by @toger5 in https://github.com/element-hq/element-x-android/pull/6642
+* Revert PR #6642 by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6724
+* Use 'Report a problem' string instead of 'Report bug' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6735
+### 🐛 Bugfixes
+* Remove distributed tracing of the 'timeline loading' flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6644
+* Set max lines for 'in reply to' view conditionally by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6612
+* Mention pill cut off by @bmarty in https://github.com/element-hq/element-x-android/pull/6651
+* Ensure that bottom sheet can scroll by @bmarty in https://github.com/element-hq/element-x-android/pull/6661
+* Remove legacy `mx-reply` from `toPlainText` formatted event contents by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6683
+* Fix ANRs when receiving push notifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6696
+* Mitigate a deadlock when loading room timelines by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6674
+* Fix calls on Huawei devices: skip addWebMessageListener on Chromium < 119 by @manfrommedan in https://github.com/element-hq/element-x-android/pull/6640
+* Allow cancelling room loading in Home screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6723
+* Let our Json parser accept comments and trailing comma. by @bmarty in https://github.com/element-hq/element-x-android/pull/6700
+* Fix low width image message by @krbns in https://github.com/element-hq/element-x-android/pull/6692
+* Make icons in the Chat screen top bar 16dp by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6733
+* Fix back button sometimes not working after exiting a thread by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6732
+* Make send event state UI easier to click by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6739
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6658
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6716
+### 🧱 Build
+* Fix record screenshots action permissions by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6679
+* Fix dependency error by @bmarty in https://github.com/element-hq/element-x-android/pull/6697
+### 🚧 In development 🚧
+* [Link new device] Add missing screen to render digits that the user has to type on the other device by @bmarty in https://github.com/element-hq/element-x-android/pull/6680
+### Dependency upgrades
+* Update dependency io.nlopez.compose.rules:detekt to v0.5.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6594
+* Update zizmorcore/zizmor-action action to v0.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6630
+* Update dependency io.sentry:sentry-android to v8.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6597
+* fix(deps): update camera to v1.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6514
+* Update dependency io.sentry:sentry-android to v8.39.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6648
+* Update dependency io.element.android:element-call-embedded to v0.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6662
+* Update dependencyAnalysis to v3.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6657
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.27 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6666
+* Update dependency io.sentry:sentry-android to v8.40.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6691
+* Update dependency org.jsoup:jsoup to v1.22.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6660
+* Update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6687
+* Update dependency androidx.compose:compose-bom to v2026.04.01 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6693
+* Update dependency io.nlopez.compose.rules:detekt to v0.5.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6711
+* Update dependency com.posthog:posthog-android to v3.43.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6704
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6718
+* Update roborazzi to v1.60.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6722
+* Update dependency net.zetetic:sqlcipher-android to v4.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6727
+* Update dependency org.maplibre.gl:android-sdk to v13.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6731
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6734
+* Update dependencyAnalysis to v3.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6742
+* Update tspascoal/get-user-teams-membership action to v4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6747
+### Others
+* devx: fix build sdk script options for macos by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6636
+* PR:Fix mention pill cut off by @krbns in https://github.com/element-hq/element-x-android/pull/6622
+* Update media viewer UI by @bmarty in https://github.com/element-hq/element-x-android/pull/6643
+* Strip formatting from media captions in room summary by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6670
+* Update error mappings for Link new device flow by @hughns in https://github.com/element-hq/element-x-android/pull/6677
+* Rename `OIDC` components and variables to `OAuth` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6686
+* [Link new device] Add missing error case "already signed in" by @bmarty in https://github.com/element-hq/element-x-android/pull/6688
+* Improve detection of completion for Link new device flow by @hughns in https://github.com/element-hq/element-x-android/pull/6681
+* Remove external call support by @bmarty in https://github.com/element-hq/element-x-android/pull/6668
+* [a11y] Fix a set of issues by @bmarty in https://github.com/element-hq/element-x-android/pull/6650
+* Add clipping to RoomSummaryRow by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6654
+* Fix media viewer flickering and crashing by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6715
+* Rename verification methods by @bmarty in https://github.com/element-hq/element-x-android/pull/6726
+* Add a way to tweak MAS url. by @bmarty in https://github.com/element-hq/element-x-android/pull/6682
+* Fix 2 x Crash the app in Developer Options - Update AppDeveloperSettingsView.kt by @escix in https://github.com/element-hq/element-x-android/pull/6708
+* Introduce UI sample by @bmarty in https://github.com/element-hq/element-x-android/pull/6740
+
+## New Contributors
+* @krbns made their first contribution in https://github.com/element-hq/element-x-android/pull/6622
+* @toger5 made their first contribution in https://github.com/element-hq/element-x-android/pull/6642
+* @manfrommedan made their first contribution in https://github.com/element-hq/element-x-android/pull/6640
+* @Copilot made their first contribution in https://github.com/element-hq/element-x-android/pull/6736
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.04.4...v26.05.0
+
+Changes in Element X v26.04.4
+=============================
+
+
+
+## What's Changed
+### 🙌 Improvements
+* Natural media viewer swiping order by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6431
+* Replace `rustls-platform-verifier-android.aar` with single class by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6610
+* Cleanup FetchPushForegroundService by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6577
+* cleaning: Remove join button from call notify timelineItemView by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6603
+### 🐛 Bugfixes
+* Fix crash when going back to threads list by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6620
+* audio: Let EC decide alone what communication device to use by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6609
+* Fix media viewer bottom sheets not being scrollable by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6631
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6626
+### 📄 Documentation
+* Updates to new features and some refactoring. by @mxandreas in https://github.com/element-hq/element-x-android/pull/6591
+### 🚧 In development 🚧
+* WIP : live location rendering by @ganfra in https://github.com/element-hq/element-x-android/pull/6611
+### Dependency upgrades
+* Update dependency io.element.android:element-call-embedded to v0.19.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6593
+* Update dependency androidx.annotation:annotation-jvm to v1.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6596
+* Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6605
+* Update dependency com.google.firebase:firebase-bom to v34.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6604
+* Update actions/upload-artifact action to v7.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6614
+* Update plugin dependencycheck to v12.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6621
+* Update actions/github-script action to v9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6606
+* Update peter-evans/create-pull-request action to v8.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6615
+* Update dependencyAnalysis to v3.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6616
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.04.21 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6635
+### Others
+* Settings UI update. by @bmarty in https://github.com/element-hq/element-x-android/pull/6602
+* Support replying to messages with voice recordings by @kalix127 in https://github.com/element-hq/element-x-android/pull/6464
+* Add Black theme option for battery saving on OLED displays by @timurgilfanov in https://github.com/element-hq/element-x-android/pull/6441
+* Fix | When selecting earpiece twice in a row the proximity sensor get wrongly disabled by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6627
+* Update wording of deactivate account screen by @bmarty in https://github.com/element-hq/element-x-android/pull/6633
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.04.3...v26.04.4
+
Changes in Element X v26.04.3
=============================
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f0191d43e0..0126d89a9c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,16 +2,16 @@
-* [Developer onboarding](#developer-onboarding)
-* [Contributing code to Matrix](#contributing-code-to-matrix)
-* [Android Studio settings](#android-studio-settings)
-* [Compilation](#compilation)
-* [Strings](#strings)
- * [I want to add new strings to the project](#i-want-to-add-new-strings-to-the-project)
+* [Contributing to Element](#contributing-to-element)
* [I want to help translating Element](#i-want-to-help-translating-element)
+ * [I want to fix a bug](#i-want-to-fix-a-bug)
+ * [I want to add a new feature or enhancement](#i-want-to-add-a-new-feature-or-enhancement)
+* [Developer onboarding](#developer-onboarding)
+ * [Submitting the PRs](#submitting-the-prs)
+ * [Android Studio settings](#android-studio-settings)
+ * [Compilation](#compilation)
+ * [Strings](#strings)
* [Element X Android Gallery](#element-x-android-gallery)
-* [I want to add a new feature to Element X Android](#i-want-to-add-a-new-feature-to-element-x-android)
-* [I want to submit a PR to fix an issue](#i-want-to-submit-a-pr-to-fix-an-issue)
* [Kotlin](#kotlin)
* [Changelog](#changelog)
* [Code quality](#code-quality)
@@ -29,69 +29,67 @@
-## Developer onboarding
-
-For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
-
-## Contributing code to Matrix
-
-If instead of contributing to the Element X Android project, you want to contribute to Synapse, the homeserver implementation, please read the [Synapse contribution guide](https://element-hq.github.io/synapse/latest/development/contributing_guide.html).
+## Contributing to Element
Element X Android support can be found in this room: [](https://matrix.to/#/#element-x-android:matrix.org).
The rest of the document contains specific rules for Matrix Android projects.
-## Android Studio settings
-
-Please set the "hard wrap" setting of Android Studio to 160 chars, this is the setting we use internally to format the source code (Menu `Settings/Editor/Code Style` then `Hard wrap at`).
-Please ensure that you're using the project formatting rules (which are in the project at .idea/codeStyles/), and format the file before committing them.
-
-## Compilation
-
-This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`.
-
-## Strings
-
-The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with Element X iOS.
-
-### I want to add new strings to the project
-
-Only the core team can modify or add English strings to Localazy. As an external contributor, if you want to add new strings, feel free to add an Android resource file to the project (for instance a file named `temporary.xml`), with a note in the description of the PR for the reviewer to integrate the String into `Localazy`. If accepted, the reviewer will add the String(s) for you, and then you can download them on your branch (following these [instructions](./tools/localazy/README.md#download-translations)) and remove the temporary file.
-
-Please follow the naming rules for the key. More details in [the dedicated section in this README.md](./tools/localazy/README.md#key-naming-rules)
-
### I want to help translating Element
To help translating, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
-- If you want to fix an issue with an English string, please open an issue on the github project of Element X (Android or iOS). Only the core team can modify or add English strings.
- If you want to fix an issue in other languages, or add a missing translation, or even add a new language, please go to [https://localazy.com/p/element](https://localazy.com/p/element).
-
-More information can be found [in this README.md](./tools/localazy/README.md).
+- If you want to fix an issue with an English string, please open an issue on the github project of Element X (Android or iOS). Only the core team can modify or add English strings. As an external contributor, if you want to add new strings, feel free to add an Android resource file to the project (for instance a file named `temporary.xml`), with a note in the description of the PR for the reviewer to integrate the String into `Localazy`. If accepted, the reviewer will add the String(s) for you, and then you can download them on your branch (following these [instructions](./tools/localazy/README.md#download-translations)) and remove the temporary file. Please follow the naming rules for the key. More details in [the dedicated section in this README.md](./tools/localazy/README.md#key-naming-rules) More information can be found [in this README.md](./tools/localazy/README.md).
Once a language is sufficiently translated, it will be added to the app. The core team will decide when a language is sufficiently translated.
+### I want to fix a bug
+
+Please check if a corresponding issue exists, if not please create one. In both cases, let us know in the comment that you've started working on it.
+
+### I want to add a new feature or enhancement
+
+To make a great product with a great user experience, all the small efforts need to go in the same direction and be aligned and consistent with each other.
+
+Before making your contribution, please consider the following:
+
+* One product can’t do everything well. Element is focusing on private end-to-end encrypted messaging and voice - this can either be for consumers (e.g. friends and family) or for professional teams and organizations. Public forums and other types of chats without E2EE remain supported but are not the primary use case in case UX compromises need to be made.
+* There are 3 platforms - Android, [iOS](https://github.com/element-hq/element-x-ios) and [Web/Desktop](https://github.com/element-hq/element-web). These platforms need to have feature parity and design consistency. For some features, supporting all platforms is a must have, in some cases exceptions can be made to have it on one platform only.
+* To make sure your idea fits both from a design/solution and use case perspective, please open a new issue (or find an existing issue) in [element-meta](https://github.com/element-hq/element-meta/issues) repository describing the use case and how you plan to tackle it. Do not just describe what feature is missing, explain why the users need it with a couple of real life examples from the field.
+ * In case of an existing issue, please comment that you're planning to contribute. If you create a new issue, please specify that in the issue. In such a case we will try to review the issue ASAP and provide you with initial feedback so you can be confident if and at which conditions your contributions will be accepted.
+
+Once we know that you want to contribute and have confirmed that the new feature is overall aligned with the product direction, the designers of the core team will help you with the designs and any other type of guidance when it comes to the user experience. We will try to unblock you as quickly as we can, but it may not be instant. Having a clear understanding of the use case and the impact of the feature will help us with the prioritization and faster responses.
+
+Only once all of the above is met should you open a PR with your proposed changes.
+
+## Developer onboarding
+
+For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
+
+### Submitting the PRs
+
+Please have a look in the [dedicated documentation](./docs/pull_request.md) about pull request.
+
+### Android Studio settings
+
+Please set the "hard wrap" setting of Android Studio to 160 chars, this is the setting we use internally to format the source code (Menu `Settings/Editor/Code Style` then `Hard wrap at`).
+Please ensure that you're using the project formatting rules (which are in the project at .idea/codeStyles/), and format the file before committing them.
+
+### Compilation
+
+This project should compile without any special action. Just clone it and open it with Android Studio, or compile from command line using `gradlew`.
+
+### Strings
+
+The strings of the project are managed externally using [https://localazy.com](https://localazy.com) and shared with Element X iOS.
+
### Element X Android Gallery
Once added to Localazy, translations can be checked screen per screen using our tool Element X Android Gallery, available at https://element-hq.github.io/element-x-android/.
Localazy syncs occur every Monday and the screenshots on this page are generated every Tuesday, so you'll have to wait to see your change appearing on Element X Android Gallery.
-## I want to add a new feature to Element X Android
-
-Thank you for contributing to the project! Please have a look in the [dedicated documentation](./docs/pull_request.md) about pull request.
-
-Also, please keep in mind that any feature added to Element X Android needs to be added to [the iOS client](https://github.com/element-hq/element-x-ios) too, unless it's related to an Android OS only behaviour.
-
-**IMPORTANT:** if you are adding new screens or modifying existing ones, this needs acceptance from the product and design teams before being merged. For this, it's better to start with a [feature request issue](https://github.com/element-hq/element-x-android/issues/new?template=enhancement.yml) describing the change you want to make and the motivation behind it instead of directly creating a pull request. This will allow the product and design teams to give feedback on the change before you start working on it, and avoid you doing work that might end up being rejected.
-
-## I want to submit a PR to fix an issue
-
-Please have a look in the [dedicated documentation](./docs/pull_request.md) about pull request.
-
-Please check if a corresponding issue exists. If yes, please let us know in a comment that you're working on it.
-If an issue does not exist yet, it may be relevant to open a new issue and let us know that you're implementing it.
-
### Kotlin
This project is full Kotlin. Please do not write Java classes.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a4ee1c8459..da90ec82e4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -103,13 +103,13 @@ android {
logger.warnInBox("Building ${defaultConfig.applicationId} ($baseAppName) [$buildType]")
buildTypes {
- val oidcRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android"
+ val oAuthRedirectSchemeBase = BuildTimeConfig.METADATA_HOST_REVERSED ?: "io.element.android"
getByName("debug") {
resValue("string", "app_name", "$baseAppName dbg")
resValue(
"string",
"login_redirect_scheme",
- "$oidcRedirectSchemeBase.debug",
+ "$oAuthRedirectSchemeBase.debug",
)
applicationIdSuffix = ".debug"
signingConfig = signingConfigs.getByName("debug")
@@ -120,7 +120,7 @@ android {
resValue(
"string",
"login_redirect_scheme",
- oidcRedirectSchemeBase,
+ oAuthRedirectSchemeBase,
)
signingConfig = signingConfigs.getByName("debug")
@@ -157,7 +157,7 @@ android {
resValue(
"string",
"login_redirect_scheme",
- "$oidcRedirectSchemeBase.nightly",
+ "$oAuthRedirectSchemeBase.nightly",
)
matchingFallbacks += listOf("release")
signingConfig = signingConfigs.getByName("nightly")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6041fbb118..d63e18ec1a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -75,7 +75,7 @@
android:scheme="elementx" />
diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
index 502518bf3c..a882dd8769 100644
--- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt
+++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
@@ -71,6 +71,7 @@ class MainActivity : NodeActivity() {
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appBindings.preferencesStore(),
+ featureFlagService = appBindings.featureFlagService(),
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = appBindings.buildMeta()
diff --git a/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt b/app/src/main/kotlin/io/element/android/x/oidc/DefaultOAuthRedirectUrlProvider.kt
similarity index 82%
rename from app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt
rename to app/src/main/kotlin/io/element/android/x/oidc/DefaultOAuthRedirectUrlProvider.kt
index ad4f9a47b2..16db564aaf 100644
--- a/app/src/main/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProvider.kt
+++ b/app/src/main/kotlin/io/element/android/x/oidc/DefaultOAuthRedirectUrlProvider.kt
@@ -10,14 +10,14 @@ package io.element.android.x.oidc
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
-import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
+import io.element.android.libraries.matrix.api.auth.OAuthRedirectUrlProvider
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.x.R
@ContributesBinding(AppScope::class)
-class DefaultOidcRedirectUrlProvider(
+class DefaultOAuthRedirectUrlProvider(
private val stringProvider: StringProvider,
-) : OidcRedirectUrlProvider {
+) : OAuthRedirectUrlProvider {
override fun provide() = buildString {
append(stringProvider.getString(R.string.login_redirect_scheme))
append(":/")
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
index c95b3a5cc0..a77f42817d 100644
--- a/app/src/main/res/xml/locales_config.xml
+++ b/app/src/main/res/xml/locales_config.xml
@@ -2,6 +2,7 @@
+
diff --git a/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt b/app/src/test/kotlin/io/element/android/x/oidc/DefaultOAuthRedirectUrlProviderTest.kt
similarity index 89%
rename from app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt
rename to app/src/test/kotlin/io/element/android/x/oidc/DefaultOAuthRedirectUrlProviderTest.kt
index 18567355d2..c26e3dc692 100644
--- a/app/src/test/kotlin/io/element/android/x/oidc/DefaultOidcRedirectUrlProviderTest.kt
+++ b/app/src/test/kotlin/io/element/android/x/oidc/DefaultOAuthRedirectUrlProviderTest.kt
@@ -13,13 +13,13 @@ import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.x.R
import org.junit.Test
-class DefaultOidcRedirectUrlProviderTest {
+class DefaultOAuthRedirectUrlProviderTest {
@Test
fun `test provide`() {
val stringProvider = FakeStringProvider(
defaultResult = "str"
)
- val sut = DefaultOidcRedirectUrlProvider(
+ val sut = DefaultOAuthRedirectUrlProvider(
stringProvider = stringProvider,
)
val result = sut.provide()
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
index 45496acb77..64b9b76a14 100644
--- a/appconfig/build.gradle.kts
+++ b/appconfig/build.gradle.kts
@@ -48,6 +48,8 @@ android {
}
dependencies {
+ implementation(libs.coroutines.core)
implementation(libs.androidx.annotationjvm)
+ implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api)
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/ProtectionConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ProtectionConfig.kt
new file mode 100644
index 0000000000..f6ad71eeb1
--- /dev/null
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ProtectionConfig.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.appconfig
+
+object ProtectionConfig {
+ /**
+ * The maximum length of a room name, to limit attack vectors in room invite.
+ */
+ const val MAX_ROOM_NAME_LENGTH = 128
+}
diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts
index 24a0355b3f..7440ecd2bf 100644
--- a/appnav/build.gradle.kts
+++ b/appnav/build.gradle.kts
@@ -33,13 +33,14 @@ dependencies {
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
- implementation(projects.libraries.oidc.api)
+ implementation(projects.libraries.oauth.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
+ implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api)
@@ -59,7 +60,7 @@ dependencies {
testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.libraries.oidc.test)
+ testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
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 44c1060e10..14230d7c5a 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -54,6 +54,7 @@ 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.location.api.live.ActiveLiveLocationShareManager
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
@@ -77,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -144,11 +146,13 @@ class LoggedInFlowNode(
private val syncService: SyncService,
private val enterpriseService: EnterpriseService,
private val appPreferencesStore: AppPreferencesStore,
+ private val featureFlagService: FeatureFlagService,
private val buildMeta: BuildMeta,
snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
+ private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -209,6 +213,7 @@ class LoggedInFlowNode(
super.onBuilt()
lifecycleScope.launch {
sessionEnterpriseService.init()
+ activeLiveLocationShareManager.setup()
}
lifecycle.subscribe(
onCreate = {
@@ -217,7 +222,6 @@ class LoggedInFlowNode(
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration()
-
sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
@@ -378,9 +382,13 @@ class LoggedInFlowNode(
}
is NavTarget.Room -> {
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
- override fun navigateToRoom(roomId: RoomId, serverNames: List) {
+ override fun onDone() {
+ backstack.pop()
+ }
+
+ override fun navigateToRoom(roomId: RoomId, serverNames: List, clearBackStack: Boolean) {
lifecycleScope.launch {
- attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = false)
+ attachRoom(roomIdOrAlias = roomId.toRoomIdOrAlias(), serverNames = serverNames, clearBackstack = clearBackStack)
}
}
@@ -671,6 +679,7 @@ class LoggedInFlowNode(
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
+ featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
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 0e458d3b9c..acf7b66db9 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
@@ -63,8 +63,8 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
-import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.api.OidcActionFlow
+import io.element.android.libraries.oauth.api.OAuthAction
+import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
@@ -95,7 +95,7 @@ class RootFlowNode(
private val signedOutEntryPoint: SignedOutEntryPoint,
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver,
- private val oidcActionFlow: OidcActionFlow,
+ private val oAuthActionFlow: OAuthActionFlow,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
@@ -392,7 +392,7 @@ class RootFlowNode(
navigateTo(resolvedIntent.deeplinkData)
}
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
- is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
+ is ResolvedIntent.OAuth -> onOAuthAction(resolvedIntent.oAuthAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.shareIntentData)
}
@@ -529,8 +529,8 @@ class RootFlowNode(
}
}
- private fun onOidcAction(oidcAction: OidcAction) {
- oidcActionFlow.post(oidcAction)
+ private fun onOAuthAction(oAuthAction: OAuthAction) {
+ oAuthActionFlow.post(oAuthAction)
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
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 9b1bbd1b81..1ce423a569 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
@@ -89,16 +89,14 @@ class SyncOrchestrator(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun observeStates() = coroutineScope.launch {
Timber.tag(tag).d("start observing the app and network state")
-
- val isAppActiveFlow = combine(
+ val isAppActiveFlows = listOf(
appForegroundStateService.isInForeground,
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
- ) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
- isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
- }
-
+ appForegroundStateService.isSharingLiveLocation
+ )
+ val isAppActiveFlow = combine(isAppActiveFlows) { actives -> actives.any { it } }
combine(
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
syncService.syncState.debounce(100.milliseconds),
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
index 6844db3ed6..ee316f00aa 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt
@@ -18,13 +18,13 @@ import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
-import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.api.OidcIntentResolver
+import io.element.android.libraries.oauth.api.OAuthAction
+import io.element.android.libraries.oauth.api.OAuthIntentResolver
import timber.log.Timber
sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
- data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
+ data class OAuth(val oAuthAction: OAuthAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val shareIntentData: ShareIntentData) : ResolvedIntent
@@ -34,7 +34,7 @@ sealed interface ResolvedIntent {
class IntentResolver(
private val deeplinkParser: DeeplinkParser,
private val loginIntentResolver: LoginIntentResolver,
- private val oidcIntentResolver: OidcIntentResolver,
+ private val oAuthIntentResolver: OAuthIntentResolver,
private val permalinkParser: PermalinkParser,
private val shareIntentHandler: ShareIntentHandler,
) {
@@ -45,9 +45,9 @@ class IntentResolver(
val deepLinkData = deeplinkParser.getFromIntent(intent)
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
- // Coming during login using Oidc?
- val oidcAction = oidcIntentResolver.resolve(intent)
- if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
+ // Coming during login using OAuth?
+ val oAuthAction = oAuthIntentResolver.resolve(intent)
+ if (oAuthAction != null) return ResolvedIntent.OAuth(oAuthAction)
val actionViewData = intent
.takeIf { it.action == Intent.ACTION_VIEW }
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
index 8dc2de5e4e..752d10e7a9 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
@@ -31,7 +31,6 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
-import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -177,7 +176,6 @@ class LoggedInPresenter(
}
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
- matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
- matrixClient.getAccountManagementUrl(AccountManagementAction.DevicesList)
+ matrixClient.getAccountManagementUrl(null)
}
}
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 febd15e9c2..dbe53b75ac 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
@@ -82,7 +82,8 @@ class JoinedRoomLoadedFlowNode(
plugins = plugins,
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
- fun navigateToRoom(roomId: RoomId, serverNames: List)
+ fun onDone()
+ fun navigateToRoom(roomId: RoomId, serverNames: List, clearBackStack: Boolean = false)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun navigateToGlobalNotificationSettings()
fun navigateToDeveloperSettings()
@@ -142,6 +143,10 @@ class JoinedRoomLoadedFlowNode(
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
val callback = object : RoomDetailsEntryPoint.Callback {
+ override fun onDone() {
+ callback.onDone()
+ }
+
override fun navigateToGlobalNotificationSettings() {
callback.navigateToGlobalNotificationSettings()
}
@@ -150,7 +155,7 @@ class JoinedRoomLoadedFlowNode(
callback.navigateToDeveloperSettings()
}
- override fun navigateToRoom(roomId: RoomId, serverNames: List) {
+ override fun navigateToRoom(roomId: RoomId, serverNames: List, clearBackStack: Boolean) {
callback.navigateToRoom(roomId, serverNames)
}
diff --git a/appnav/src/main/res/values-ca/translations.xml b/appnav/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..d251b3a6b1
--- /dev/null
+++ b/appnav/src/main/res/values-ca/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Tanca sessió i actualitza"
+ "%1$s ja no admet el protocol antic. Tanca sessió i torna a entrar per continuar utilitzant l\'aplicació."
+ "El servidor utilitzat ja no admet el protocol antic. Tanca sessió i torna-la a iniciar per continuar utilitzant l\'aplicació."
+
diff --git a/appnav/src/main/res/values-zh/translations.xml b/appnav/src/main/res/values-zh/translations.xml
index 406471196e..f6eac30310 100644
--- a/appnav/src/main/res/values-zh/translations.xml
+++ b/appnav/src/main/res/values-zh/translations.xml
@@ -1,6 +1,6 @@
- "登出并升级"
+ "注销并升级""%1$s 不再支持旧协议。请注销并重新登录以继续使用该应用程序。"
- "您的服务器不再支持旧协议。请登出并重新登录以继续使用此应用。"
+ "你的主服务器不再支持旧协议。请注销并重新登录以继续使用此 app。"
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
index 576e1aaea6..451ca279f8 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt
@@ -26,8 +26,8 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
-import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.test.FakeOidcIntentResolver
+import io.element.android.libraries.oauth.api.OAuthAction
+import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Test
import org.junit.runner.RunWith
@@ -170,9 +170,9 @@ class IntentResolverTest {
}
@Test
- fun `test resolve oidc`() {
+ fun `test resolve OAuth`() {
val sut = createIntentResolver(
- oidcIntentResolverResult = { OidcAction.GoBack() },
+ oAuthIntentResolverResult = { OAuthAction.GoBack() },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -180,8 +180,8 @@ class IntentResolverTest {
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
- ResolvedIntent.Oidc(
- oidcAction = OidcAction.GoBack()
+ ResolvedIntent.OAuth(
+ oAuthAction = OAuthAction.GoBack()
)
)
}
@@ -194,7 +194,7 @@ class IntentResolverTest {
val sut = createIntentResolver(
loginIntentResolverResult = { null },
permalinkParserResult = { permalinkData },
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -213,7 +213,7 @@ class IntentResolverTest {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -230,7 +230,7 @@ class IntentResolverTest {
)
val sut = createIntentResolver(
permalinkParserResult = { permalinkData },
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_BATTERY_LOW
@@ -244,7 +244,7 @@ class IntentResolverTest {
fun `test incoming share simple`() {
val shareIntentData = ShareIntentData.PlainText("Hello")
val sut = createIntentResolver(
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@@ -260,7 +260,7 @@ class IntentResolverTest {
val fileUri = "content://com.example.app/file1.jpg".toUri()
val shareIntentData = ShareIntentData.Uris(text = "Hello", uris = listOf(UriToShare(fileUri, "image/jpg")))
val sut = createIntentResolver(
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
onIncomingShareIntent = { shareIntentData },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
@@ -277,7 +277,7 @@ class IntentResolverTest {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -292,7 +292,7 @@ class IntentResolverTest {
val aLoginParams = LoginParams("accountProvider", null)
val sut = createIntentResolver(
loginIntentResolverResult = { aLoginParams },
- oidcIntentResolverResult = { null },
+ oAuthIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -306,7 +306,7 @@ class IntentResolverTest {
deeplinkParserResult: DeeplinkData? = null,
permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
- oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() },
+ oAuthIntentResolverResult: (Intent) -> OAuthAction? = { lambdaError() },
onIncomingShareIntent: (Intent) -> ShareIntentData? = { null },
): IntentResolver {
return IntentResolver(
@@ -314,8 +314,8 @@ class IntentResolverTest {
loginIntentResolver = FakeLoginIntentResolver(
parseResult = loginIntentResolverResult,
),
- oidcIntentResolver = FakeOidcIntentResolver(
- resolveResult = oidcIntentResolverResult,
+ oAuthIntentResolver = FakeOAuthIntentResolver(
+ resolveResult = oAuthIntentResolverResult,
),
permalinkParser = FakePermalinkParser(
result = permalinkParserResult
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
index d147a4ed68..18c8cfd7b9 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
@@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
-import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
+import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
@@ -71,7 +71,7 @@ class LoggedInPresenterTest {
}
@Test
- fun `present - ensure that account urls are preloaded`() = runTest {
+ fun `present - ensure that account url is preloaded`() = runTest {
val accountManagementUrlResult = lambdaRecorder> { Result.success("aUrl") }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = accountManagementUrlResult,
@@ -81,11 +81,8 @@ class LoggedInPresenterTest {
).test {
awaitItem()
advanceUntilIdle()
- accountManagementUrlResult.assertions().isCalledExactly(2)
- .withSequence(
- listOf(value(AccountManagementAction.Profile)),
- listOf(value(AccountManagementAction.DevicesList)),
- )
+ accountManagementUrlResult.assertions().isCalledOnce()
+ .with(value(null))
}
}
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt
index 2f17071870..ec36f3d32d 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/room/joined/FakeJoinedRoomLoadedFlowNodeCallback.kt
@@ -13,7 +13,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.lambda.lambdaError
class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
- override fun navigateToRoom(roomId: RoomId, serverNames: List) = lambdaError()
+ override fun onDone() = lambdaError()
+ override fun navigateToRoom(roomId: RoomId, serverNames: List, clearBackStack: Boolean) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError()
override fun navigateToDeveloperSettings() = lambdaError()
diff --git a/build.gradle.kts b/build.gradle.kts
index f699378d54..474b868eda 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -46,12 +46,15 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.5.6")
+ detektPlugins("io.nlopez.compose.rules:detekt:0.5.8")
detektPlugins(project(":tests:detekt-rules"))
}
tasks.withType().configureEach {
exclude("io/element/android/tests/konsist/failures/**")
+
+ // This file comes from another project and we want to keep it as close to the original as possible
+ exclude("org/rustls/platformverifier/**")
}
// KtLint
@@ -79,6 +82,9 @@ allprojects {
// This file comes from another project and we want to keep it as close to the original as possible
exclude("**/SafeChildrenTransitionScope.kt")
+
+ // This file comes from another project and we want to keep it as close to the original as possible
+ exclude("org/rustls/platformverifier/**")
}
}
// Dependency check
diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md
index a264bfec63..74020ead98 100644
--- a/docs/_developer_onboarding.md
+++ b/docs/_developer_onboarding.md
@@ -144,6 +144,11 @@ Prerequisites:
export ANDROID_HOME=$HOME/android/sdk
```
+* On macos ensure gnu-getopt is installed
+ ```
+ brew install gnu-getopt
+ ```
+
You can then build the Rust SDK by running the script
[`tools/sdk/build-rust-sdk`](../tools/sdk/build-rust-sdk). Type
`./tools/sdk/build-rust-sdk --help` for help.
diff --git a/docs/oidc.md b/docs/oauth.md
similarity index 81%
rename from docs/oidc.md
rename to docs/oauth.md
index 23709b608c..1080c64b0e 100644
--- a/docs/oidc.md
+++ b/docs/oauth.md
@@ -1,4 +1,4 @@
-This file contains some rough notes about Oidc implementation, with some examples of actual data.
+This file contains some rough notes about OAuth implementation, with some examples of actual data.
[ios implementation](https://github.com/element-hq/element-x-ios/compare/develop...doug/oidc-temp)
@@ -25,7 +25,7 @@ tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
-Example of OidcData (from presentUrl callback):
+Example of OAuthData (from presentUrl callback):
url: https://auth-oidc.lab.element.dev/authorize?response_type=code&client_id=01GYCAGG3PA70CJ97ZVP0WFJY3&redirect_uri=io.element%3A%2Fcallback&scope=openid+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Aapi%3A*+urn%3Amatrix%3Aorg.matrix.msc2967.client%3Adevice%3AYAgcPW4mcG&state=ex6mNJVFZ5jn9wL8&nonce=NZ93DOyIGQd9exPQ&code_challenge_method=S256&code_challenge=FFRcPALNSPCh-ZgpyTRFu_h8NZJVncfvihbfT9CyX8U&prompt=consent
Formatted url:
@@ -43,8 +43,8 @@ https://auth-oidc.lab.element.dev/authorize?
state: ex6mNJVFZ5jn9wL8
-Oidc client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
-Oidc sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
+OAuth client example: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/examples/oidc_cli/src/main.rs
+OAuth sdk doc: https://github.com/matrix-org/matrix-rust-sdk/blob/39ad8a46801fb4317a777ebf895822b3675b709c/crates/matrix-sdk/src/oidc.rs
Test server:
diff --git a/enterprise b/enterprise
index cdde60c158..6781da90aa 160000
--- a/enterprise
+++ b/enterprise
@@ -1 +1 @@
-Subproject commit cdde60c158ecd0987a3ba6fd79a4617551aff463
+Subproject commit 6781da90aae61cf77dcdbc543e18d76411d578b4
diff --git a/fastlane/metadata/android/en-US/changelogs/202604040.txt b/fastlane/metadata/android/en-US/changelogs/202604040.txt
new file mode 100644
index 0000000000..cbb77b7606
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202604040.txt
@@ -0,0 +1,2 @@
+Main changes in this version: several bug fixes.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202605000.txt b/fastlane/metadata/android/en-US/changelogs/202605000.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202605000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/202605010.txt b/fastlane/metadata/android/en-US/changelogs/202605010.txt
new file mode 100644
index 0000000000..0ad08f5b4d
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202605010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: improvements in Element Call, room knocking and room directory are now available, improvements on DMs.
+Full changelog: https://github.com/element-hq/element-x-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/202605020.txt b/fastlane/metadata/android/en-US/changelogs/202605020.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202605020.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/element-hq/element-x-android/releases
\ No newline at end of file
diff --git a/features/analytics/api/src/main/res/values-ca/translations.xml b/features/analytics/api/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..9f352d28ce
--- /dev/null
+++ b/features/analytics/api/src/main/res/values-ca/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Comparteix dades d\'ús anònimes per ajudar-nos a identificar problemes."
+ "Pots llegir tots els nostres termes %1$s."
+ "aquí"
+ "Comparteix dades analítiques"
+
diff --git a/features/analytics/api/src/main/res/values-ja/translations.xml b/features/analytics/api/src/main/res/values-ja/translations.xml
index 5554ee162f..e1495271d3 100644
--- a/features/analytics/api/src/main/res/values-ja/translations.xml
+++ b/features/analytics/api/src/main/res/values-ja/translations.xml
@@ -1,7 +1,7 @@
- "問題発見のため、匿名の使用データの共有にご協力ください。"
- "利用規約の全文を%1$sから確認することができます。"
+ "改善のため、匿名の使用データの共有にご協力ください。"
+ "規約の全文は%1$sから確認することができます。""こちら""使用データを共有"
diff --git a/features/analytics/api/src/main/res/values-zh/translations.xml b/features/analytics/api/src/main/res/values-zh/translations.xml
index e5f9fccd66..8f1c1699d9 100644
--- a/features/analytics/api/src/main/res/values-zh/translations.xml
+++ b/features/analytics/api/src/main/res/values-zh/translations.xml
@@ -1,7 +1,7 @@
"共享匿名使用数据以帮助我们排查问题。"
- "您可以阅读我们的所有条款 %1$s。"
+ "你可以点击 %1$s 阅读我们的所有条款。""此处""共享分析数据"
diff --git a/features/analytics/impl/src/main/res/values-ca/translations.xml b/features/analytics/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..5a2b633075
--- /dev/null
+++ b/features/analytics/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,10 @@
+
+
+ "No registrarem ni elaborarem perfils de cap dada personal"
+ "Comparteix dades d\'ús anònimes per ajudar-nos a identificar problemes."
+ "Pots llegir tots els nostres termes %1$s."
+ "aquí"
+ "Ho pots desactivar en qualsevol moment"
+ "No compartirem les teves dades amb tercers"
+ "Ajuda\'ns a millorar %1$s"
+
diff --git a/features/analytics/impl/src/main/res/values-ja/translations.xml b/features/analytics/impl/src/main/res/values-ja/translations.xml
index 2cee69962c..162e01ecb0 100644
--- a/features/analytics/impl/src/main/res/values-ja/translations.xml
+++ b/features/analytics/impl/src/main/res/values-ja/translations.xml
@@ -1,8 +1,8 @@
"いかなる個人情報も記録, 分析されることはありません"
- "問題発見のため、匿名の使用データの共有にご協力ください。"
- "利用規約の全文を%1$sから確認することができます。"
+ "改善のため、匿名の使用データの共有にご協力ください。"
+ "規約の全文は%1$sから確認することができます。""こちら""いつでも設定は変更できます""情報が第三者に共有されることはありません"
diff --git a/features/analytics/impl/src/main/res/values-zh/translations.xml b/features/analytics/impl/src/main/res/values-zh/translations.xml
index d18650654b..678d506287 100644
--- a/features/analytics/impl/src/main/res/values-zh/translations.xml
+++ b/features/analytics/impl/src/main/res/values-zh/translations.xml
@@ -2,9 +2,9 @@
"我们不会记录或分析任何个人数据""共享匿名使用数据以帮助我们排查问题。"
- "您可以阅读我们的所有条款 %1$s。"
+ "你可以点击 %1$s 阅读我们的所有条款。""此处""可以随时关闭此功能"
- "我们不会与第三方共享您的数据"
+ "我们不会与第三方共享你的数据""帮助改进 %1$s"
diff --git a/features/announcement/impl/src/main/res/values-bg/translations.xml b/features/announcement/impl/src/main/res/values-bg/translations.xml
deleted file mode 100644
index 853cf5f027..0000000000
--- a/features/announcement/impl/src/main/res/values-bg/translations.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- "Присъединете се към обществени пространства"
-
diff --git a/features/announcement/impl/src/main/res/values-cs/translations.xml b/features/announcement/impl/src/main/res/values-cs/translations.xml
deleted file mode 100644
index cf7ead1962..0000000000
--- a/features/announcement/impl/src/main/res/values-cs/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Zobrazit prostory, které jste vytvořili nebo ke kterým jste se připojili"
- "Přijmout nebo odmítnout pozvánky do prostorů"
- "Objevte všechny místnosti, do kterých můžete vstoupit ve svých prostorech"
- "Připojit se k veřejným prostorům"
- "Opustit všechny prostory, ke kterým jste se připojili"
- "Filtrování, vytváření a správa prostorů bude brzy k dispozici."
- "Vítejte v beta verzi prostorů! S touto první verzí můžete:"
- "Představujeme prostory"
-
diff --git a/features/announcement/impl/src/main/res/values-da/translations.xml b/features/announcement/impl/src/main/res/values-da/translations.xml
deleted file mode 100644
index 76540962e1..0000000000
--- a/features/announcement/impl/src/main/res/values-da/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Se klynger, du har oprettet eller tilmeldt dig"
- "Acceptere eller afvise invitationer til klynger"
- "Finde alle rum, du kan deltage i, i dine klynger"
- "Deltage i offentlige klynger"
- "Forlade de klynger, du har tilsluttet dig"
- "Filtrering, oprettelse og administration af klynger kommer snart."
- "Velkommen til betaversionen af Klynger! Med denne første version kan du:"
- "Introduktion til Klynger"
-
diff --git a/features/announcement/impl/src/main/res/values-de/translations.xml b/features/announcement/impl/src/main/res/values-de/translations.xml
deleted file mode 100644
index 11f5f3a99c..0000000000
--- a/features/announcement/impl/src/main/res/values-de/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Von dir erstellte oder beigetretene Spaces anzeigen"
- "Einladungen zu Spaces annehmen oder ablehnen"
- "Chats innerhalb deiner Spaces entdecken, um ihnen beizutreten"
- "Öffentlichen Spaces beitreten"
- "Spaces verlassen, bei denen du Mitglied bist"
- "Das Filtern, Erstellen und Verwalten von Spaces ist bald verfügbar."
- "Willkommen bei der Beta-Version von Spaces! Mit dieser ersten Version kannst du:"
- "Einführung in Spaces"
-
diff --git a/features/announcement/impl/src/main/res/values-el/translations.xml b/features/announcement/impl/src/main/res/values-el/translations.xml
deleted file mode 100644
index bdeb821efb..0000000000
--- a/features/announcement/impl/src/main/res/values-el/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Δείτε τους χώρους που έχετε δημιουργήσει ή στους οποίους έχετε εγγραφεί"
- "Να αποδεχθείτε ή να απορρίψετε προσκλήσεις σε χώρους"
- "Να ανακαλύψτε όλες τις αίθουσες που μπορείτε να συμμετάσχετε στους χώρους σας"
- "Να συμμετάσχετε σε δημόσιους χώρους"
- "Να αποχωρήστε από χώρους στους οποίους έχετε συμμετάσχει"
- "Το φιλτράρισμα, η δημιουργία και η διαχείριση χώρων θα είναι σύντομα διαθέσιμα."
- "Καλώς ορίσατε στην δοκιμαστική έκδοση των Χώρων! Με αυτήν την πρώτη έκδοση μπορείτε:"
- "Παρουσιάζοντας τους Χώρους"
-
diff --git a/features/announcement/impl/src/main/res/values-et/translations.xml b/features/announcement/impl/src/main/res/values-et/translations.xml
deleted file mode 100644
index ee2ba9c3b4..0000000000
--- a/features/announcement/impl/src/main/res/values-et/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Vaadata kogukondi, mille oled loonud või millega oled liitunud"
- "Nõustuda kutsetega liitumiseks kogukonnaga või sellest keelduda"
- "Uurida neis kogukondades leiduvaid jututube ning nendega liituda"
- "Liituda avalike kogukondadega"
- "Lahkuda kogukonnast, millega oled liitunud"
- "Kogukondade filtreerimine, loomine ja haldamine lisandub peagi"
- "Tere tulemast kasutama kogukondade beetaversiooni! Selles esimeses versioonis saad sa:"
- "Võtame kasutusele kogukonnad"
-
diff --git a/features/announcement/impl/src/main/res/values-fa/translations.xml b/features/announcement/impl/src/main/res/values-fa/translations.xml
deleted file mode 100644
index 2e8902ae23..0000000000
--- a/features/announcement/impl/src/main/res/values-fa/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "دیدن فضاهایی که ساخته یا پیوستهاید"
- "پذیرش یا رد دعوتها به فضاها"
- "کشف تمامی اتاقهایی که میتوانید در فضاهایتان بپیوندید"
- "پیوستن به فضاهای عمومی"
- "ترک هر فضایی که پیوستهاید"
- "پالایش، ایجاد و مدیریت کردن فضاها به زودی."
- "به نگارش آزمایشی فضاها خوش آمدید! در این نگارش میتوانید:"
- "معرّفی فضاها"
-
diff --git a/features/announcement/impl/src/main/res/values-fi/translations.xml b/features/announcement/impl/src/main/res/values-fi/translations.xml
deleted file mode 100644
index 8e7674487f..0000000000
--- a/features/announcement/impl/src/main/res/values-fi/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Nähdä luomasi tai liittymäsi tilat"
- "Hyväksyä tai hylätä kutsuja tiloihin"
- "Löytää kaikki huoneet, joihin voit liittyä tiloissasi"
- "Liittyä julkisiin tiloihin"
- "Poistua mistä tahansa tilasta, johon olet liittynyt"
- "Tilojen suodatus, luominen ja hallinta on tulossa pian."
- "Tervetuloa tilojen beetaversioon! Tämän ensimmäisen version avulla voit:"
- "Esittelyssä tilat"
-
diff --git a/features/announcement/impl/src/main/res/values-fr/translations.xml b/features/announcement/impl/src/main/res/values-fr/translations.xml
deleted file mode 100644
index 7e042c65ff..0000000000
--- a/features/announcement/impl/src/main/res/values-fr/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Voir les espaces que vous avez créés ou rejoints"
- "Accepter ou refuser les invitations aux espaces"
- "Découvrir les salons que vous pouvez joindre depuis vos espaces"
- "Rejoindre les espaces publics"
- "Quitter les espaces dont vous êtes membre."
- "Le filtrage, la création et la gestion des espaces seront bientôt disponibles."
- "Bienvenue dans la version bêta des espaces! Avec cette première version, vous pourrez :"
- "Ajout des espaces"
-
diff --git a/features/announcement/impl/src/main/res/values-hr/translations.xml b/features/announcement/impl/src/main/res/values-hr/translations.xml
deleted file mode 100644
index e78f29f19a..0000000000
--- a/features/announcement/impl/src/main/res/values-hr/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "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-hu/translations.xml b/features/announcement/impl/src/main/res/values-hu/translations.xml
deleted file mode 100644
index b09f70419b..0000000000
--- a/features/announcement/impl/src/main/res/values-hu/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Az Ön által létrehozott vagy csatlakozott térek megtekintése"
- "A meghívások elfogadására vagy elutasítására a terekhez"
- "Szobák felfedezése a terekben, amelyekhez csatlakozhat"
- "Csatlakozás nyilvános terekhez"
- "Terek elhagyása"
- "Terek szűrése, készítése és kezelése hamarosan érkezik."
- "Üdvözöljük a tér béta verziójában! Ezzel az első verzióval a következőket teheti:"
- "Bemutatkoznak a terek"
-
diff --git a/features/announcement/impl/src/main/res/values-it/translations.xml b/features/announcement/impl/src/main/res/values-it/translations.xml
deleted file mode 100644
index 584ddcdf21..0000000000
--- a/features/announcement/impl/src/main/res/values-it/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Visualizza gli spazi che hai creato o a cui partecipi"
- "Accetta o rifiuta gli inviti agli spazi"
- "Scopri tutte le stanze a cui puoi partecipare nei tuoi spazi"
- "Unisciti agli spazi pubblici"
- "Lascia tutti gli spazi a cui ti sei unito"
- "A breve saranno disponibili le funzionalità di filtraggio, creazione e gestione degli spazi."
- "Benvenuti alla versione beta degli Spazi! Con questa prima versione potrete:"
- "Ti presentiamo gli Spazi"
-
diff --git a/features/announcement/impl/src/main/res/values-ja/translations.xml b/features/announcement/impl/src/main/res/values-ja/translations.xml
deleted file mode 100644
index 47273cc12d..0000000000
--- a/features/announcement/impl/src/main/res/values-ja/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "作成または参加したスペースを表示できます"
- "スペースへの招待を受諾または拒否できます"
- "スペース内の参加可能なルームを検索できます"
- "公開スペースに参加できます"
- "参加したスペースを退出できます"
- "スペースの作成や管理, フィルター検索は近日実装予定です。"
- "ベータ版のスペースにようこそ。この最新のバージョンでは:"
- "スペースの紹介"
-
diff --git a/features/announcement/impl/src/main/res/values-ko/translations.xml b/features/announcement/impl/src/main/res/values-ko/translations.xml
deleted file mode 100644
index 3fbf2b953c..0000000000
--- a/features/announcement/impl/src/main/res/values-ko/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "직접 만들거나 참여 중인 스페이스 보기"
- "스페이스 초대 수락 또는 거절"
- "참여 가능한 스페이스 내 모든 방 탐색"
- "공개 스페이스 참여"
- "참여 중인 스페이스 나가기"
- "스페이스 필터링, 생성 및 관리 기능이 곧 추가될 예정입니다."
- "스페이스 베타 버전에 오신 것을 환영합니다! 이번 첫 번째 버전에서는 다음과 같은 기능을 이용하실 수 있습니다.:"
- "스페이스 소개"
-
diff --git a/features/announcement/impl/src/main/res/values-nb/translations.xml b/features/announcement/impl/src/main/res/values-nb/translations.xml
deleted file mode 100644
index 553ff9f997..0000000000
--- a/features/announcement/impl/src/main/res/values-nb/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Se områder du har opprettet eller blitt med i"
- "Godta eller avslå invitasjoner til områder"
- "Oppdag alle rom du kan bli med i i dine områder"
- "Bli med i offentlige områder"
- "Forlat områder du har blitt med i"
- "Oppretting, filtrering og administrasjon av områder kommer snart."
- "Velkommen til betaversjonen av Områder! Med denne første versjonen kan du:"
- "Vi introduserer Områder"
-
diff --git a/features/announcement/impl/src/main/res/values-pl/translations.xml b/features/announcement/impl/src/main/res/values-pl/translations.xml
deleted file mode 100644
index 4308bdd81d..0000000000
--- a/features/announcement/impl/src/main/res/values-pl/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Wyświetlić przestrzenie, które stworzyłeś lub do których dołączyłeś"
- "Akceptować lub odrzucać zaproszenia"
- "Odkrywać wszystkie pokoje, do których możesz dołączyć w swoich przestrzeniach"
- "Dołączać do przestrzeni publicznych"
- "Opuszczać jakąkolwiek przestrzeń, do której dołączyłeś"
- "Filtrowanie, tworzenie i zarządzanie przestrzeniami pojawi się wkrótce."
- "Witamy w wersji beta przestrzeni! W tej wersji możesz:"
- "Przedstawiamy przestrzenie"
-
diff --git a/features/announcement/impl/src/main/res/values-pt-rBR/translations.xml b/features/announcement/impl/src/main/res/values-pt-rBR/translations.xml
deleted file mode 100644
index 32a9bf85af..0000000000
--- a/features/announcement/impl/src/main/res/values-pt-rBR/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Visualizar espaços que criou ou entrou"
- "Aceitar ou recusar convites aos espaços"
- "Descobrir quaisquer salas que você pode entrar nos espaços"
- "Entrar espaços públicos"
- "Sair de quaisquer espaços que entrou"
- "Filtrar, criar, e gerenciar espaços virão em breve."
- "Boas-vindas à versão beta dos Espaços! Com essa primeira versão, você pode:"
- "Apresentando Espaços"
-
diff --git a/features/announcement/impl/src/main/res/values-pt/translations.xml b/features/announcement/impl/src/main/res/values-pt/translations.xml
deleted file mode 100644
index 744ac74bd3..0000000000
--- a/features/announcement/impl/src/main/res/values-pt/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Ver espaços que criaste ou nos quais entraste"
- "Aceitar ou recusar convites para espaços"
- "Descobrir todas as salas dos seus espaços nas quais podes entrar"
- "Entrar em espaços públicos"
- "Deixar todos os espaços em que entraste"
- "Em breve, será possível filtrar, criar e gerir espaços."
- "Eis a versão beta dos Espaços! Nesta primeira versão, podes:"
- "Apresentamos os Espaços"
-
diff --git a/features/announcement/impl/src/main/res/values-ro/translations.xml b/features/announcement/impl/src/main/res/values-ro/translations.xml
deleted file mode 100644
index 716f1faeb2..0000000000
--- a/features/announcement/impl/src/main/res/values-ro/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Vizualizați spațiile pe care le-ați creat sau la care v-ați alăturat"
- "Acceptați sau refuzați invitațiile la spații"
- "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."
- "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/announcement/impl/src/main/res/values-ru/translations.xml b/features/announcement/impl/src/main/res/values-ru/translations.xml
deleted file mode 100644
index 46d005c8cd..0000000000
--- a/features/announcement/impl/src/main/res/values-ru/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Просматривать пространства, которые вы создали или к которым присоединились"
- "Принимать или отклонять приглашения в пространства"
- "Находить все комнаты, к которым можно присоединиться в ваших пространствах"
- "Присоединяться к публичным пространствам"
- "Покидать все пространства, к которым вы присоединились"
- "Фильтровать, создавать пространства и управлять ими можно будет позже."
- "Добро пожаловать в бета-версию пространств! Сейчас вы сможете:"
- "Представляем пространства"
-
diff --git a/features/announcement/impl/src/main/res/values-sk/translations.xml b/features/announcement/impl/src/main/res/values-sk/translations.xml
deleted file mode 100644
index 0b305499a7..0000000000
--- a/features/announcement/impl/src/main/res/values-sk/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Zobraziť priestory, ktoré ste vytvorili alebo ku ktorým ste sa pripojili"
- "Prijímať alebo odmietať pozvánky do priestorov"
- "Objaviť všetky miestnosti, do ktorých sa môžete pripojiť vo svojich priestoroch"
- "Pripojiť sa k verejnému priestoru"
- "Opustiť akékoľvek priestory, ku ktorým ste sa pridali"
- "Filtrovanie, vytváranie a správa priestorov bude čoskoro k dispozícii."
- "Vitajte v beta verzii priestorov! S touto prvou verziou môžete:"
- "Predstavujeme priestory"
-
diff --git a/features/announcement/impl/src/main/res/values-tr/translations.xml b/features/announcement/impl/src/main/res/values-tr/translations.xml
deleted file mode 100644
index 8551dbb02b..0000000000
--- a/features/announcement/impl/src/main/res/values-tr/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Oluşturduğunuz veya katıldığınız alanları görüntüleyin"
- "Alan davetlerini kabul edin veya reddedin"
- "Alanlarınızdaki katılabileceğiniz odaları keşfedin"
- "Herkese açık alanlara katılın"
- "Katıldığınız alanlardan ayrılın"
- "Alanları filtreleme, oluşturma ve yönetme yakında geliyor."
- "Alanlar’ın beta sürümüne hoş geldiniz! Bu ilk sürümle şunları yapabilirsiniz:"
- "Alanlar ile tanışın"
-
diff --git a/features/announcement/impl/src/main/res/values-uk/translations.xml b/features/announcement/impl/src/main/res/values-uk/translations.xml
deleted file mode 100644
index de3c1b0324..0000000000
--- a/features/announcement/impl/src/main/res/values-uk/translations.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
- "Знаходьте у своїх просторах кімнати, до яких можна приєднатися"
- "Фільтрування, створення та керування просторами стане доступним найближчим часом."
- "Ласкаво просимо до бета-версії Просторів! У цій першій версії ви можете:"
- "Представляємо Простори"
-
diff --git a/features/announcement/impl/src/main/res/values-uz/translations.xml b/features/announcement/impl/src/main/res/values-uz/translations.xml
deleted file mode 100644
index 12356160b8..0000000000
--- a/features/announcement/impl/src/main/res/values-uz/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "Siz yaratgan yoki qo‘shilgan maydonlarni ko‘rish"
- "Maydonlarga takliflarni qabul qilish yoki rad etish"
- "Maydonlaringizga qo‘shilishingiz mumkin bo‘lgan xonalarni kashf eting"
- "Jamoat maydonlariga qo‘shilish"
- "Kirgan maydonlaringizni tark eting"
- "Maydonlarni filtrlash, yaratish va boshqarish tez orada amalga oshiriladi."
- "Maydonlar beta versiyasiga xush kelibsiz! Bu birinchi versiya bilan siz:"
- "Maydonlar bilan tanishish"
-
diff --git a/features/announcement/impl/src/main/res/values-zh-rTW/translations.xml b/features/announcement/impl/src/main/res/values-zh-rTW/translations.xml
deleted file mode 100644
index a5b82752bc..0000000000
--- a/features/announcement/impl/src/main/res/values-zh-rTW/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "檢視您建立或加入的空間"
- "接受或拒絕空間邀請"
- "探索空間內您可以加入的任何聊天室"
- "加入公開空間"
- "離開任何您已加入的空間"
- "篩選、建立與管理空間功能即將推出。"
- "歡迎使用空間的測試版!此初始版本可讓您:"
- "介紹空間"
-
diff --git a/features/announcement/impl/src/main/res/values-zh/translations.xml b/features/announcement/impl/src/main/res/values-zh/translations.xml
deleted file mode 100644
index 70d86638ea..0000000000
--- a/features/announcement/impl/src/main/res/values-zh/translations.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "查看您创建或加入的空间"
- "接受或拒绝空间邀请"
- "发现您可以加入空间的所有房间"
- "加入公共空间"
- "离开你加入的所有空间"
- "筛选、创建及管理空间功能即将上线。"
- "欢迎使用空间测试版!使用首个版本,您可以:"
- "空间简介"
-
diff --git a/features/announcement/impl/src/main/res/values/localazy.xml b/features/announcement/impl/src/main/res/values/localazy.xml
deleted file mode 100644
index 5e7b8a6713..0000000000
--- a/features/announcement/impl/src/main/res/values/localazy.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- "View spaces you\'ve created or joined"
- "Accept or decline invites to spaces"
- "Discover any rooms you can join in your spaces"
- "Join public spaces"
- "Leave any spaces you’ve joined"
- "Filtering, creating and managing spaces is coming soon."
- "Welcome to the beta version of Spaces! With this first version you can:"
- "Introducing Spaces"
-
diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt
index b69037e61a..b7932898a8 100644
--- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt
+++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.announcement.impl.fullscreen
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent
@@ -20,43 +23,39 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
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 FullscreenAnnouncementViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back sends a AnnouncementEvent`() {
+ fun `clicking on back sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setFullscreenAnnouncementView(
+ setFullscreenAnnouncementView(
anAnnouncementState(
announcement = Announcement.Fullscreen.Space,
eventSink = eventsRecorder,
),
)
- rule.pressBackKey()
+ pressBackKey()
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
}
@Test
- fun `clicking on Continue sends a AnnouncementEvent`() {
+ fun `clicking on Continue sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setFullscreenAnnouncementView(
+ setFullscreenAnnouncementView(
anAnnouncementState(
announcement = Announcement.Fullscreen.Space,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
}
}
-private fun AndroidComposeTestRule.setFullscreenAnnouncementView(
+private fun AndroidComposeUiTest.setFullscreenAnnouncementView(
state: AnnouncementState,
) {
setContent {
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallData.kt
similarity index 50%
rename from features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt
rename to features/call/api/src/main/kotlin/io/element/android/features/call/api/CallData.kt
index 4b09813418..c1dcf573c6 100644
--- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallType.kt
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CallData.kt
@@ -14,22 +14,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
-sealed interface CallType : NodeInputs, Parcelable {
- @Parcelize
- data class ExternalUrl(val url: String) : CallType {
- override fun toString(): String {
- return "ExternalUrl"
- }
- }
-
- @Parcelize
- data class RoomCall(
- val sessionId: SessionId,
- val roomId: RoomId,
- val isAudioCall: Boolean
- ) : CallType {
- override fun toString(): String {
- return "RoomCall(sessionId=$sessionId, roomId=$roomId, isAudioCall=$isAudioCall)"
- }
- }
-}
+@Parcelize
+data class CallData(
+ val sessionId: SessionId,
+ val roomId: RoomId,
+ val isAudioCall: Boolean
+) : NodeInputs, Parcelable
diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
index caa557f4de..2976635ee2 100644
--- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
+++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt
@@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.UserId
interface ElementCallEntryPoint {
/**
* Start a call of the given type.
- * @param callType The type of call to start.
+ * @param callData The data of call to start.
*/
- fun startCall(callType: CallType)
+ fun startCall(callData: CallData)
/**
* Handle an incoming call.
- * @param callType The type of call.
+ * @param callData The data of call.
* @param eventId The event id of the event that started the call.
* @param senderId The user id of the sender of the event that started the call.
* @param roomName The name of the room the call is in.
@@ -35,7 +35,7 @@ interface ElementCallEntryPoint {
* @param textContent The text content of the notification. If null the default content from the system will be used.
*/
suspend fun handleIncomingCall(
- callType: CallType.RoomCall,
+ callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,
diff --git a/features/call/impl/src/main/AndroidManifest.xml b/features/call/impl/src/main/AndroidManifest.xml
index daf1a910c9..c35c6843ff 100644
--- a/features/call/impl/src/main/AndroidManifest.xml
+++ b/features/call/impl/src/main/AndroidManifest.xml
@@ -30,44 +30,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:taskAffinity="io.element.android.features.call" />
(null) }
- fun handleEvent(event: PictureInPictureEvents) {
+ fun handleEvent(event: PictureInPictureEvent) {
when (event) {
- is PictureInPictureEvents.SetPipController -> {
+ is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController
}
- PictureInPictureEvents.EnterPictureInPicture -> {
+ PictureInPictureEvent.EnterPictureInPicture -> {
coroutineScope.launch {
switchToPip(pipController)
}
}
- is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
+ is PictureInPictureEvent.OnPictureInPictureModeChanged -> {
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
isInPictureInPicture = event.isInPip
if (event.isInPip) {
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt
index b1fef4f28b..108589edb9 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt
@@ -11,5 +11,5 @@ package io.element.android.features.call.impl.pip
data class PictureInPictureState(
val supportPip: Boolean,
val isInPictureInPicture: Boolean,
- val eventSink: (PictureInPictureEvents) -> Unit,
+ val eventSink: (PictureInPictureEvent) -> Unit,
)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
index 6324820eec..f4a78294b6 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt
@@ -11,7 +11,7 @@ package io.element.android.features.call.impl.pip
fun aPictureInPictureState(
supportPip: Boolean = false,
isInPictureInPicture: Boolean = false,
- eventSink: (PictureInPictureEvents) -> Unit = {},
+ eventSink: (PictureInPictureEvent) -> Unit = {},
): PictureInPictureState {
return PictureInPictureState(
supportPip = supportPip,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
index 179e6c2b22..bf27e8d39d 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt
@@ -13,7 +13,7 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.IntentCompat
import dev.zacsweers.metro.Inject
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager
@@ -42,7 +42,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
context.bindings().inject(this)
appCoroutineScope.launch {
activeCallManager.hangUpCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt
index 3a51a014df..9e551b3e1b 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallNotificationDataProvider.kt
@@ -9,6 +9,8 @@ package io.element.android.features.call.impl.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.call.impl.notifications.CallNotificationData
+import io.element.android.libraries.designsystem.preview.ROOM_NAME
+import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -34,8 +36,8 @@ internal fun aCallNotificationData(
roomId = RoomId("!1234:matrix.org"),
eventId = EventId("\$asdadadsad:matrix.org"),
senderId = UserId("@bob:matrix.org"),
- roomName = "A room",
- senderName = "Bob",
+ roomName = ROOM_NAME,
+ senderName = USER_NAME_BOB,
avatarUrl = null,
notificationChannelId = "incoming_call",
timestamp = 0L,
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt
new file mode 100644
index 0000000000..cd47cd8bb1
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicy.kt
@@ -0,0 +1,26 @@
+/*
+ * 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.call.impl.ui
+internal sealed interface CallScreenBackPressAction {
+ data object DispatchEscapeToWebView : CallScreenBackPressAction
+ data object EnterPictureInPicture : CallScreenBackPressAction
+}
+
+internal object CallScreenBackPressPolicy {
+ fun resolve(
+ supportPip: Boolean,
+ hasWebView: Boolean,
+ fromNative: Boolean,
+ ): CallScreenBackPressAction? {
+ return when {
+ hasWebView && fromNative -> CallScreenBackPressAction.DispatchEscapeToWebView
+ hasWebView && supportPip -> CallScreenBackPressAction.EnterPictureInPicture
+ else -> null
+ }
+ }
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvent.kt
similarity index 78%
rename from features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt
rename to features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvent.kt
index 8fbbce896f..357559c3f9 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvents.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenEvent.kt
@@ -10,8 +10,8 @@ package io.element.android.features.call.impl.ui
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
-sealed interface CallScreenEvents {
- data object Hangup : CallScreenEvents
- data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents
- data class OnWebViewError(val description: String?) : CallScreenEvents
+sealed interface CallScreenEvent {
+ data object Hangup : CallScreenEvent
+ data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvent
+ data class OnWebViewError(val description: String?) : CallScreenEvent
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
index da2c57c0ac..7d8e20967f 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
@@ -23,7 +23,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider
@@ -52,7 +52,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject
class CallScreenPresenter(
- @Assisted private val callType: CallType,
+ @Assisted private val callData: CallData,
@Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider,
userAgentProvider: UserAgentProvider,
@@ -69,10 +69,9 @@ class CallScreenPresenter(
) : Presenter {
@AssistedFactory
interface Factory {
- fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
+ fun create(callData: CallData, navigator: CallScreenNavigator): CallScreenPresenter
}
- private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide()
@Composable
@@ -90,9 +89,9 @@ class CallScreenPresenter(
DisposableEffect(Unit) {
coroutineScope.launch {
// Sets the call as joined
- activeCallManager.joinedCall(callType)
+ activeCallManager.joinedCall(callData)
fetchRoomCallUrl(
- inputs = callType,
+ callData = callData,
urlState = urlState,
callWidgetDriver = callWidgetDriver,
languageTag = languageTag,
@@ -100,19 +99,10 @@ class CallScreenPresenter(
)
}
onDispose {
- appCoroutineScope.launch { activeCallManager.hangUpCall(callType) }
+ appCoroutineScope.launch { activeCallManager.hangUpCall(callData) }
}
}
-
- when (callType) {
- is CallType.ExternalUrl -> {
- // No analytics yet for external calls
- }
- is CallType.RoomCall -> {
- screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
- }
- }
-
+ screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
HandleMatrixClientSyncState()
callWidgetDriver.value?.let { driver ->
@@ -149,25 +139,22 @@ class CallScreenPresenter(
.launchIn(this)
}
- if (callType is CallType.RoomCall) {
- // Note: For external calls isWidgetLoaded will always be false
- LaunchedEffect(Unit) {
- // Wait for the call to be joined, if it takes too long, we display an error
- delay(10.seconds)
+ LaunchedEffect(Unit) {
+ // Wait for the call to be joined, if it takes too long, we display an error
+ delay(10.seconds)
- if (!isWidgetLoaded) {
- Timber.w("The call took too long to load. Displaying an error before exiting.")
+ if (!isWidgetLoaded) {
+ Timber.w("The call took too long to load. Displaying an error before exiting.")
- // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
- webViewError = ""
- }
+ // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
+ webViewError = ""
}
}
}
- fun handleEvent(event: CallScreenEvents) {
+ fun handleEvent(event: CallScreenEvent) {
when (event) {
- is CallScreenEvents.Hangup -> {
+ is CallScreenEvent.Hangup -> {
val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isWidgetLoaded) {
@@ -187,10 +174,10 @@ class CallScreenPresenter(
}
}
}
- is CallScreenEvents.SetupMessageChannels -> {
+ is CallScreenEvent.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor
}
- is CallScreenEvents.OnWebViewError -> {
+ is CallScreenEvent.OnWebViewError -> {
if (!ignoreWebViewError) {
webViewError = event.description.orEmpty()
}
@@ -204,37 +191,29 @@ class CallScreenPresenter(
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isWidgetLoaded,
- isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvent,
)
}
private suspend fun fetchRoomCallUrl(
- inputs: CallType,
+ callData: CallData,
urlState: MutableState>,
callWidgetDriver: MutableState,
languageTag: String?,
theme: String?,
) {
urlState.runCatchingUpdatingState {
- when (inputs) {
- is CallType.ExternalUrl -> {
- inputs.url
- }
- is CallType.RoomCall -> {
- val result = callWidgetProvider.getWidget(
- sessionId = inputs.sessionId,
- roomId = inputs.roomId,
- clientId = UUID.randomUUID().toString(),
- isAudioCall = inputs.isAudioCall,
- languageTag = languageTag,
- theme = theme,
- ).getOrThrow()
- callWidgetDriver.value = result.driver
- Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
- result.url
- }
- }
+ val result = callWidgetProvider.getWidget(
+ sessionId = callData.sessionId,
+ roomId = callData.roomId,
+ clientId = UUID.randomUUID().toString(),
+ isAudioCall = callData.isAudioCall,
+ languageTag = languageTag,
+ theme = theme,
+ ).getOrThrow()
+ callWidgetDriver.value = result.driver
+ Timber.d("Call widget driver initialized for sessionId: ${callData.sessionId}, roomId: ${callData.roomId}")
+ result.url
}
}
@@ -242,12 +221,11 @@ class CallScreenPresenter(
private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) {
- val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {}
- val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose {
- Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
+ val client = matrixClientsProvider.getOrNull(callData.sessionId) ?: return@DisposableEffect onDispose {
+ Timber.w("No MatrixClient found for sessionId, can't send call notification: ${callData.sessionId}")
}
coroutineScope.launch {
- Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}")
+ Timber.d("Observing sync state in-call for sessionId: ${callData.sessionId}")
client.syncService.syncState
.collect { state ->
if (state != SyncState.Running) {
@@ -256,7 +234,7 @@ class CallScreenPresenter(
}
}
onDispose {
- Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}")
+ Timber.d("Stopped observing sync state in-call for sessionId: ${callData.sessionId}")
// Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false)
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt
index c07594aebb..86b4cc439f 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt
@@ -15,6 +15,5 @@ data class CallScreenState(
val webViewError: String?,
val userAgent: String,
val isCallActive: Boolean,
- val isInWidgetMode: Boolean,
- val eventSink: (CallScreenEvents) -> Unit,
+ val eventSink: (CallScreenEvent) -> Unit,
)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
index 3e72f96f87..155c5d3380 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
@@ -26,15 +26,13 @@ internal fun aCallScreenState(
webViewError: String? = null,
userAgent: String = "",
isCallActive: Boolean = true,
- isInWidgetMode: Boolean = false,
- eventSink: (CallScreenEvents) -> Unit = {},
+ eventSink: (CallScreenEvent) -> Unit = {},
): CallScreenState {
return CallScreenState(
urlState = urlState,
webViewError = webViewError,
userAgent = userAgent,
isCallActive = isCallActive,
- isInWidgetMode = isInWidgetMode,
eventSink = eventSink,
)
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
index f8657a9ece..2537eb739c 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
@@ -17,9 +17,10 @@ import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -33,7 +34,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.call.impl.R
-import io.element.android.features.call.impl.pip.PictureInPictureEvents
+import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
@@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.ProgressDialog
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
@@ -64,94 +64,93 @@ internal fun CallScreenView(
requestPermissions: (Array, RequestPermissionCallback) -> Unit,
modifier: Modifier = Modifier,
) {
- fun handleBack() {
- if (pipState.supportPip) {
- pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture)
- } else {
- state.eventSink(CallScreenEvents.Hangup)
+ var callWebView by remember { mutableStateOf(null) }
+
+ fun handleBack(fromNative: Boolean = false) {
+ when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
+ CallScreenBackPressAction.EnterPictureInPicture ->
+ pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
+ CallScreenBackPressAction.DispatchEscapeToWebView ->
+ callWebView?.dispatchEscKeyEvent()
+ null -> Timber.d("Back press with unsupported pip is a no-op")
}
}
- Scaffold(
- modifier = modifier,
- ) { padding ->
- BackHandler {
- handleBack()
+ BackHandler {
+ handleBack(fromNative = true)
+ }
+ if (state.webViewError != null) {
+ ErrorDialog(
+ content = buildString {
+ append(stringResource(CommonStrings.error_unknown))
+ state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
+ },
+ onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
+ )
+ } else {
+ var webViewAudioManager by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+
+ var invalidAudioDeviceReason by remember { mutableStateOf(null) }
+ invalidAudioDeviceReason?.let {
+ InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
+ invalidAudioDeviceReason = null
+ }
}
- if (state.webViewError != null) {
- ErrorDialog(
- content = buildString {
- append(stringResource(CommonStrings.error_unknown))
- state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
- },
- onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
- )
- } else {
- var webViewAudioManager by remember { mutableStateOf(null) }
- val coroutineScope = rememberCoroutineScope()
- var invalidAudioDeviceReason by remember { mutableStateOf(null) }
- invalidAudioDeviceReason?.let {
- InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
- invalidAudioDeviceReason = null
- }
+ CallWebView(
+ modifier = modifier.consumeWindowInsets(WindowInsets.systemBars).fillMaxSize(),
+ url = state.urlState,
+ userAgent = state.userAgent,
+ onPermissionsRequest = { request ->
+ val androidPermissions = mapWebkitPermissions(request.resources)
+ val callback: RequestPermissionCallback = { request.grant(it) }
+ requestPermissions(androidPermissions.toTypedArray(), callback)
+ },
+ onConsoleMessage = onConsoleMessage,
+ onCreateWebView = { webView ->
+ callWebView = webView
+ webView.addBackHandler(onBackPressed = ::handleBack)
+ val interceptor = WebViewWidgetMessageInterceptor(
+ webView = webView,
+ onUrlLoaded = { url ->
+ webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
+ if (webViewAudioManager?.isInCallMode?.get() == false) {
+ Timber.d("URL $url is loaded, starting in-call audio mode")
+ webViewAudioManager?.onCallStarted()
+ } else {
+ Timber.d("Can't start in-call audio mode since the app is already in it.")
+ }
+ },
+ onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
+ )
+ webViewAudioManager = WebViewAudioManager(
+ webView = webView,
+ coroutineScope = coroutineScope,
+ onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
+ )
+ state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
+ val pipController = WebViewPipController(webView)
+ pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
+ },
+ onDestroyWebView = {
+ callWebView = null
+ // Reset audio mode
+ webViewAudioManager?.onCallStopped()
}
-
- CallWebView(
- modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .fillMaxSize(),
- url = state.urlState,
- userAgent = state.userAgent,
- onPermissionsRequest = { request ->
- val androidPermissions = mapWebkitPermissions(request.resources)
- val callback: RequestPermissionCallback = { request.grant(it) }
- requestPermissions(androidPermissions.toTypedArray(), callback)
- },
- onConsoleMessage = onConsoleMessage,
- onCreateWebView = { webView ->
- webView.addBackHandler(onBackPressed = ::handleBack)
- val interceptor = WebViewWidgetMessageInterceptor(
- webView = webView,
- onUrlLoaded = { url ->
- webView.evaluateJavascript("controls.onBackButtonPressed = () => { backHandler.onBackPressed() }", null)
- if (webViewAudioManager?.isInCallMode?.get() == false) {
- Timber.d("URL $url is loaded, starting in-call audio mode")
- webViewAudioManager?.onCallStarted()
- } else {
- Timber.d("Can't start in-call audio mode since the app is already in it.")
- }
- },
- onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
- )
- webViewAudioManager = WebViewAudioManager(
- webView = webView,
- coroutineScope = coroutineScope,
- onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
- )
- state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
- val pipController = WebViewPipController(webView)
- pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
- },
- onDestroyWebView = {
- // Reset audio mode
- webViewAudioManager?.onCallStopped()
- }
- )
- when (state.urlState) {
- AsyncData.Uninitialized,
- is AsyncData.Loading ->
- ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
- is AsyncData.Failure -> {
- Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
- ErrorDialog(
- content = state.urlState.error.message.orEmpty(),
- onSubmit = { state.eventSink(CallScreenEvents.Hangup) },
- )
- }
- is AsyncData.Success -> Unit
+ )
+ when (state.urlState) {
+ AsyncData.Uninitialized,
+ is AsyncData.Loading ->
+ ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
+ is AsyncData.Failure -> {
+ Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
+ ErrorDialog(
+ content = state.urlState.error.message.orEmpty(),
+ onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
+ )
}
+ is AsyncData.Success -> Unit
}
}
}
@@ -248,15 +247,16 @@ private fun WebView.setup(
private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
addJavascriptInterface(
- object {
- @Suppress("unused")
- @JavascriptInterface
- fun onBackPressed() = onBackPressed()
- },
+ JavascriptBackHandlerBridge(callback = onBackPressed),
"backHandler"
)
}
+private fun WebView.dispatchEscKeyEvent() {
+ dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ESCAPE))
+ dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_ESCAPE))
+}
+
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview(
@@ -275,3 +275,12 @@ internal fun CallScreenViewPreview(
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}
+
+internal class JavascriptBackHandlerBridge(
+ private val callback: () -> Unit,
+) {
+ @JavascriptInterface
+ fun onBackPressed() {
+ callback()
+ }
+}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt
deleted file mode 100644
index 0c18c3e1a4..0000000000
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallTypeExtension.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.call.impl.ui
-
-import io.element.android.features.call.api.CallType
-import io.element.android.libraries.matrix.api.core.SessionId
-
-fun CallType.getSessionId(): SessionId? {
- return when (this) {
- is CallType.ExternalUrl -> null
- is CallType.RoomCall -> sessionId
- }
-}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
index bf4f836294..26df7c160c 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
@@ -32,19 +32,20 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.content.IntentCompat
import androidx.core.util.Consumer
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
-import io.element.android.features.call.api.CallType
-import io.element.android.features.call.api.CallType.ExternalUrl
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
-import io.element.android.features.call.impl.pip.PictureInPictureEvents
+import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService
-import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.architecture.Presenter
@@ -54,6 +55,8 @@ import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
+import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import timber.log.Timber
@@ -63,9 +66,9 @@ class ElementCallActivity :
AppCompatActivity(),
CallScreenNavigator,
PipView {
- @Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore
+ @Inject lateinit var featureFlagService: FeatureFlagService
@Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
@Inject lateinit var buildMeta: BuildMeta
@@ -78,9 +81,9 @@ class ElementCallActivity :
private val requestPermissionsLauncher = registerPermissionResultLauncher()
- private val webViewTarget = mutableStateOf(null)
+ private val webViewTarget = mutableStateOf(null)
- private var eventSink: ((CallScreenEvents) -> Unit)? = null
+ private var eventSink: ((CallScreenEvent) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -96,7 +99,7 @@ class ElementCallActivity :
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
}
- setCallType(intent)
+ setCallData(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
if (!::presenter.isInitialized) {
return
@@ -109,20 +112,41 @@ class ElementCallActivity :
setContent {
val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState)
- val colors by remember(webViewTarget.value?.getSessionId()) {
- enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId())
+ val colors by remember(webViewTarget.value?.sessionId) {
+ enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
+
+ // When the height is compact, hide the system bars by default to maximize the space for the call, using immersive mode
+ val hasCompactHeight = hasCompactHeightWindowSize()
+ DisposableEffect(hasCompactHeight, pipState.isInPictureInPicture) {
+ if (hasCompactHeight && !pipState.isInPictureInPicture) {
+ val window = this@ElementCallActivity.window ?: return@DisposableEffect onDispose {}
+ val insetsController = WindowCompat.getInsetsController(window, window.decorView)
+ val systemBarInsets = WindowInsetsCompat.Type.systemBars()
+ insetsController.hide(systemBarInsets)
+
+ insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ onDispose {
+ insetsController.show(systemBarInsets)
+ insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
+ }
+ } else {
+ onDispose {}
+ }
+ }
+
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
+ featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val state = presenter.present()
eventSink = state.eventSink
- LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
- // Note when not in WidgetMode, isCallActive will never be true, so consider the call is active
- if (state.isCallActive || !state.isInWidgetMode) {
+ LaunchedEffect(state.isCallActive) {
+ if (state.isCallActive) {
setCallIsActive()
}
}
@@ -160,7 +184,7 @@ class ElementCallActivity :
if (requestPermissionCallback != null) {
Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else {
- pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
+ pipEventSink(PictureInPictureEvent.EnterPictureInPicture)
}
}
addOnUserLeaveHintListener(listener)
@@ -170,10 +194,10 @@ class ElementCallActivity :
}
DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
- pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
+ pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
- eventSink?.invoke(CallScreenEvents.Hangup)
+ eventSink?.invoke(CallScreenEvent.Hangup)
}
}
addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
@@ -185,7 +209,7 @@ class ElementCallActivity :
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- setCallType(intent)
+ setCallData(intent)
}
override fun onDestroy() {
@@ -204,25 +228,24 @@ class ElementCallActivity :
finish()
}
- private fun setCallType(intent: Intent?) {
- val callType = intent?.let {
- IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java)
- ?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
+ private fun setCallData(intent: Intent?) {
+ val callData = intent?.let {
+ IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallData::class.java)
}
- val currentCallType = webViewTarget.value
- if (currentCallType == null) {
- if (callType == null) {
+ val currentCallData = webViewTarget.value
+ if (currentCallData == null) {
+ if (callData == null) {
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
finish()
} else {
Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
- webViewTarget.value = callType
- presenter = presenterFactory.create(callType, this)
+ webViewTarget.value = callData
+ presenter = presenterFactory.create(callData, this)
}
} else {
- if (callType == null) {
+ if (callData == null) {
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
- } else if (callType != currentCallType) {
+ } else if (callData != currentCallData) {
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
setIntent(intent)
recreate()
@@ -233,8 +256,6 @@ class ElementCallActivity :
}
}
- private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
-
private fun registerPermissionResultLauncher(): ActivityResultLauncher> {
return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -284,7 +305,7 @@ class ElementCallActivity :
}
override fun hangUp() {
- eventSink?.invoke(CallScreenEvents.Hangup)
+ eventSink?.invoke(CallScreenEvent.Hangup)
}
}
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
index 73233fe453..1d6989fb3c 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt
@@ -19,7 +19,7 @@ import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData
@@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.di.annotations.AppCoroutineScope
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
@@ -57,6 +58,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject
lateinit var appPreferencesStore: AppPreferencesStore
+ @Inject
+ lateinit var featureFlagService: FeatureFlagService
+
@Inject
lateinit var enterpriseService: EnterpriseService
@@ -88,6 +92,7 @@ class IncomingCallActivity : AppCompatActivity() {
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
+ featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
@@ -113,10 +118,10 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(
- CallType.RoomCall(
- notificationData.sessionId,
- notificationData.roomId,
- isAudioCall = notificationData.audioOnly
+ CallData(
+ sessionId = notificationData.sessionId,
+ roomId = notificationData.roomId,
+ isAudioCall = notificationData.audioOnly,
)
)
}
@@ -124,7 +129,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch {
- activeCallManager.hangUpCall(callType = activeCall.callType)
+ activeCallManager.hangUpCall(callData = activeCall.callData)
}
}
}
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 99679a8afb..685fc932fe 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
@@ -20,7 +20,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
@@ -73,20 +73,20 @@ interface ActiveCallManager {
/**
* Called to hang up the active call. It will hang up the call and remove any existing UI and the active call.
- * @param callType The type of call that the user hangs up, either an external url one or a room one.
+ * @param callData The data about the call.
* @param notificationData The data for the incoming call notification.
*/
suspend fun hangUpCall(
- callType: CallType,
+ callData: CallData,
notificationData: CallNotificationData? = null,
)
/**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
*
- * @param callType The type of call that the user joined, either an external url one or a room one.
+ * @param callData The data about the call.
*/
- suspend fun joinedCall(callType: CallType)
+ suspend fun joinedCall(callData: CallData)
}
@SingleIn(AppScope::class)
@@ -143,7 +143,7 @@ class DefaultActiveCallManager(
return
}
activeCall.value = ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = notificationData.sessionId,
roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly,
@@ -198,17 +198,17 @@ class DefaultActiveCallManager(
}
override suspend fun hangUpCall(
- callType: CallType,
+ callData: CallData,
notificationData: CallNotificationData?,
) = mutex.withLock {
- Timber.tag(tag).d("Hang up call: $callType")
+ Timber.tag(tag).d("Hang up call: $callData")
cancelIncomingCallNotification()
val currentActiveCall = activeCall.value ?: run {
// activeCall.value can be null if the application has been killed while the call was ringing
// Build a currentActiveCall with the provided parameters.
notificationData?.let {
ActiveCall(
- callType = callType,
+ callData = callData,
callState = CallState.Ringing(
notificationData = notificationData,
)
@@ -219,8 +219,8 @@ class DefaultActiveCallManager(
return@withLock
}
- if (currentActiveCall.callType != callType) {
- Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
+ if (currentActiveCall.callData != callData) {
+ Timber.tag(tag).w("Call type $callData does not match the active call type, ignoring")
return@withLock
}
if (currentActiveCall.callState is CallState.Ringing) {
@@ -244,8 +244,8 @@ class DefaultActiveCallManager(
activeCall.value = null
}
- override suspend fun joinedCall(callType: CallType) = mutex.withLock {
- Timber.tag(tag).d("Joined call: $callType")
+ override suspend fun joinedCall(callData: CallData) = mutex.withLock {
+ Timber.tag(tag).d("Joined call: $callData")
cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call")
@@ -254,7 +254,7 @@ class DefaultActiveCallManager(
timedOutCallJob?.cancel()
activeCall.value = ActiveCall(
- callType = callType,
+ callData = callData,
callState = CallState.InCall,
)
}
@@ -307,15 +307,15 @@ class DefaultActiveCallManager(
private fun observeRingingCall() {
activeCall
.filterNotNull()
- .filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
+ .filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall ->
- val callType = activeCall.callType as CallType.RoomCall
+ val callData = activeCall.callData
val ringingInfo = activeCall.callState as CallState.Ringing
- val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
+ val client = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
- val room = client.getRoom(callType.roomId) ?: run {
+ val room = client.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
@@ -346,17 +346,17 @@ class DefaultActiveCallManager(
// has joined the call from another session.
activeCall
.filterNotNull()
- .filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
+ .filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall ->
- val callType = activeCall.callType as CallType.RoomCall
+ val callData = activeCall.callData
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
- val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run {
+ val room = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull()?.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf()
}
room.roomInfoFlow.map {
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
- it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
+ it.hasRoomCall to (callData.sessionId in it.activeRoomCallParticipants)
}
}
// We only want to check if the room active call status changes
@@ -388,10 +388,7 @@ class DefaultActiveCallManager(
// Nothing to do
}
is CallState.InCall -> {
- when (val callType = value.callType) {
- is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
- is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
- }
+ defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(value.callData.roomId))
}
}
}
@@ -404,7 +401,7 @@ class DefaultActiveCallManager(
* Represents an active call.
*/
data class ActiveCall(
- val callType: CallType,
+ val callData: CallData,
val callState: CallState,
)
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt
deleted file mode 100644
index f5433c15a0..0000000000
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/CallIntentDataParser.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.call.impl.utils
-
-import android.net.Uri
-import androidx.core.net.toUri
-import dev.zacsweers.metro.Inject
-
-@Inject
-class CallIntentDataParser {
- private val validHttpSchemes = sequenceOf("https")
- private val knownHosts = sequenceOf(
- "call.element.io",
- )
-
- fun parse(data: String?): String? {
- val parsedUrl = data?.toUri() ?: return null
- val scheme = parsedUrl.scheme
- return when {
- scheme in validHttpSchemes -> parsedUrl
- scheme == "element" && parsedUrl.host == "call" -> {
- parsedUrl.getUrlParameter()
- }
- scheme == "io.element.call" && parsedUrl.host == null -> {
- parsedUrl.getUrlParameter()
- }
- // This should never be possible, but we still need to take into account the possibility
- else -> null
- }
- ?.takeIf { it.host in knownHosts }
- ?.withCustomParameters()
- }
-
- private fun Uri.getUrlParameter(): Uri? {
- return getQueryParameter("url")
- ?.let { urlParameter ->
- urlParameter.toUri().takeIf { uri ->
- uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
- }
- }
- }
-}
-
-/**
- * Ensure the uri has the following parameters and value in the fragment:
- * - appPrompt=false
- * - confineToRoom=true
- * to ensure that the rendering will bo correct on the embedded Webview.
- */
-private fun Uri.withCustomParameters(): String {
- val builder = buildUpon()
- // Remove the existing query parameters
- builder.clearQuery()
- queryParameterNames.forEach {
- if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
- builder.appendQueryParameter(it, getQueryParameter(it))
- }
- // Remove the existing fragment parameters, and build the new fragment
- val currentFragment = fragment ?: ""
- // Reset the current fragment
- builder.fragment("")
- val queryFragmentPosition = currentFragment.lastIndexOf("?")
- val newFragment = if (queryFragmentPosition == -1) {
- // No existing query, build it.
- "$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
- } else {
- buildString {
- append(currentFragment.substring(0, queryFragmentPosition + 1))
- val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
- // Replace the existing parameters
- val newQueryFragment = queryFragment
- .replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
- .replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
- append(newQueryFragment)
- // Ensure the parameters are there
- if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
- if (newQueryFragment.isNotEmpty()) {
- append("&")
- }
- append("$APP_PROMPT_PARAMETER=false")
- }
- if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
- append("&$CONFINE_TO_ROOM_PARAMETER=true")
- }
- }
- }
- // We do not want to encode the Fragment part, so append it manually
- return builder.build().toString() + "#" + newFragment
-}
-
-private const val APP_PROMPT_PARAMETER = "appPrompt"
-private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
index b31b6152d0..8de7b81d6d 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt
@@ -14,7 +14,6 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt
index 0f74ba86d4..c6c607cbbc 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/IntentProvider.kt
@@ -12,21 +12,21 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.PendingIntentCompat
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider {
- fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply {
- putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType)
+ fun createIntent(context: Context, callData: CallData): Intent = Intent(context, ElementCallActivity::class.java).apply {
+ putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callData)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
}
- fun getPendingIntent(context: Context, callType: CallType): PendingIntent {
+ fun getPendingIntent(context: Context, callData: CallData): PendingIntent {
return PendingIntentCompat.getActivity(
context,
DefaultElementCallEntryPoint.REQUEST_CODE,
- createIntent(context, callType),
+ createIntent(context, callData),
PendingIntent.FLAG_CANCEL_CURRENT,
false
)!!
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
index 0c1ecf83cb..febd919149 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt
@@ -64,6 +64,11 @@ class WebViewAudioManager(
*/
private val isWebViewAudioEnabled = AtomicBoolean(true)
+ /**
+ * Store the device id requested by EC, and re-set it if something try to switch (only android S+).
+ */
+ private var ecRequestedDeviceId: String? = null
+
/**
* The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
*/
@@ -113,22 +118,12 @@ class WebViewAudioManager(
@get:RequiresApi(Build.VERSION_CODES.S)
private val commsDeviceChangedListener by lazy {
AudioManager.OnCommunicationDeviceChangedListener { device ->
- if (device != null && device.id == expectedNewCommunicationDeviceId) {
- expectedNewCommunicationDeviceId = null
- Timber.d("Audio device changed, type: ${device.type}")
- updateSelectedAudioDeviceInWebView(device.id.toString())
- } else if (device != null && device.id != expectedNewCommunicationDeviceId) {
- // We were expecting a device change but it didn't happen, so we should retry
- val expectedDeviceId = expectedNewCommunicationDeviceId
- if (expectedDeviceId != null) {
- // Remove the expected id so we only retry once
- expectedNewCommunicationDeviceId = null
- audioManager.selectAudioDevice(expectedDeviceId.toString())
- }
- } else {
- Timber.d("Audio device cleared")
- expectedNewCommunicationDeviceId = null
- audioManager.selectAudioDevice(null)
+ Timber.d("Audio device changed, type: ${device?.id}")
+ val wantedDevice = this.ecRequestedDeviceId
+ if (wantedDevice != null && this.ecRequestedDeviceId != device?.id?.toString()) {
+ // We want to ensure that we stick to what EC selected even if it was changed outside
+ Timber.d("Audio device changed to unwanted device ${device?.id}, enforce using the expected device $wantedDevice")
+ audioManager.selectAudioDevice(wantedDevice)
}
}
}
@@ -144,40 +139,15 @@ class WebViewAudioManager(
// We need to calculate the available devices ourselves, since calling `listAudioDevices` will return an outdated list
val audioDevices = (listAudioDevices() + validNewDevices).distinctBy { it.id }.sortedWith(audioDeviceComparator)
setAvailableAudioDevices(audioDevices.map(SerializableAudioDevice::fromAudioDeviceInfo))
- // This should automatically switch to a new device if it has a higher priority than the current one
- selectDefaultAudioDevice(audioDevices)
}
override fun onAudioDevicesRemoved(removedDevices: Array?) {
// Update the available devices
+ // Element Call will then decide to switch devices if needed
setAvailableAudioDevices()
-
- // Unless the removed device is the current one, we don't need to do anything else
- val removedCurrentDevice = removedDevices.orEmpty().any { it.id == currentDeviceId }
- if (!removedCurrentDevice) return
-
- val previousDevice = previousSelectedDevice
- if (previousDevice != null) {
- previousSelectedDevice = null
- // If we have a previous device, we should select it again
- audioManager.selectAudioDevice(previousDevice.id.toString())
- } else {
- // If we don't have a previous device, we should select the default one
- selectDefaultAudioDevice()
- }
}
}
- /**
- * The currently used audio device id.
- */
- private var currentDeviceId: Int? = null
-
- /**
- * When a new audio device is selected but not yet set as the communication device by the OS, this id is used to check if the device is the expected one.
- */
- private var expectedNewCommunicationDeviceId: Int? = null
-
/**
* Previously selected device, used to restore the selection when the selected device is removed.
*/
@@ -231,12 +201,9 @@ class WebViewAudioManager(
return
}
- coroutineScope.launch {
- proximitySensorMutex.withLock {
- if (proximitySensorWakeLock?.isHeld == true) {
- proximitySensorWakeLock?.release()
- }
- }
+ // Since this should run when the call is no longer running, it should be OK to not use the mutex here
+ if (proximitySensorWakeLock?.isHeld == true) {
+ proximitySensorWakeLock?.release()
}
audioManager.mode = AudioManager.MODE_NORMAL
@@ -263,6 +230,7 @@ class WebViewAudioManager(
val webViewAudioDeviceSelectedCallback = AndroidWebViewAudioBridge(
onAudioDeviceSelected = { selectedDeviceId ->
previousSelectedDevice = listAudioDevices().find { it.id.toString() == selectedDeviceId }
+ this.ecRequestedDeviceId = selectedDeviceId
audioManager.selectAudioDevice(selectedDeviceId)
},
onAudioPlaybackStarted = {
@@ -330,34 +298,6 @@ class WebViewAudioManager(
})
}
- /**
- * Selects the default audio device based on the sorted available devices.
- *
- * @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
- */
- private fun selectDefaultAudioDevice(availableDevices: List = listAudioDevices()) {
- val selectedDevice = availableDevices.firstOrNull()
- expectedNewCommunicationDeviceId = selectedDevice?.id
- audioManager.selectAudioDevice(selectedDevice)
-
- selectedDevice?.let {
- updateSelectedAudioDeviceInWebView(it.id.toString())
- } ?: run {
- Timber.w("Audio: unable to select default audio device")
- }
- }
-
- /**
- * Updates the WebView's UI to reflect the selected audio device.
- *
- * @param deviceId The id of the selected audio device.
- */
- private fun updateSelectedAudioDeviceInWebView(deviceId: String) {
- coroutineScope.launch(Dispatchers.Main) {
- webView.evaluateJavascript("controls.setOutputDevice('$deviceId');", null)
- }
- }
-
/**
* Selects the audio device on the OS based on the provided device id.
*
@@ -381,14 +321,14 @@ class WebViewAudioManager(
*
* @param device The info of the audio device to select, or none to clear the selected device.
*/
- @Suppress("DEPRECATION")
private fun AudioManager.selectAudioDevice(device: AudioDeviceInfo?) {
- currentDeviceId = device?.id
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (device != null) {
runCatchingExceptions {
Timber.d("Setting communication device: ${device.id} - ${deviceName(device.type, device.productName.toString())}")
- setCommunicationDevice(device)
+ if (!setCommunicationDevice(device)) {
+ Timber.w("Failed to setCommunication device")
+ }
}.onFailure {
Timber.e(it, "Could not set communication device.")
}
@@ -410,22 +350,24 @@ class WebViewAudioManager(
return
}
setAudioEnabled(true)
+ @Suppress("DEPRECATION")
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else {
+ @Suppress("DEPRECATION")
isSpeakerphoneOn = false
isBluetoothScoOn = false
}
}
- expectedNewCommunicationDeviceId = null
-
coroutineScope.launch {
proximitySensorMutex.withLock {
@Suppress("WakeLock", "WakeLockTimeout")
- if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE && proximitySensorWakeLock?.isHeld == false) {
- // If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
- proximitySensorWakeLock?.acquire()
+ if (device?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE) {
+ if (proximitySensorWakeLock?.isHeld == false) {
+ // If the device is the built-in earpiece, we need to acquire the proximity sensor wake lock
+ proximitySensorWakeLock?.acquire()
+ }
} else if (proximitySensorWakeLock?.isHeld == true) {
// If the device is no longer the earpiece, we need to release the wake lock
proximitySensorWakeLock?.release()
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
index f7ab2c57af..c74ae90abd 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWidgetMessageInterceptor.kt
@@ -140,26 +140,33 @@ class WebViewWidgetMessageInterceptor(
}
}
- // Create a WebMessageListener, which will receive messages from the WebView and reply to them
- val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
- onMessageReceived(message.data)
- }
+ // Always register JavascriptInterface as the baseline message channel.
+ // This works on all WebView implementations including Huawei.
+ webView.addJavascriptInterface(object {
+ @JavascriptInterface
+ fun postMessage(json: String?) {
+ onMessageReceived(json)
+ }
+ }, LISTENER_NAME)
- // Use WebMessageListener if supported, otherwise use JavascriptInterface
- if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
+ // Additionally register WebMessageListener on WebViews that reliably support it.
+ // Huawei WebView (Chromium < 119) reports WEB_MESSAGE_LISTENER as supported
+ // but silently drops messages, so we only trust it on Chromium 119+.
+ // See: https://github.com/element-hq/element-x-android/issues/6632
+ val webViewVersionName = WebViewCompat.getCurrentWebViewPackage(webView.context)?.versionName.orEmpty()
+ Timber.d("Using WebView version: $webViewVersionName")
+ val webViewVersionCode = webViewVersionName.split(".").firstOrNull()?.toIntOrNull() ?: 0
+
+ if (webViewVersionCode >= 119 &&
+ WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener(
webView,
LISTENER_NAME,
setOf("*"),
- webMessageListener
- )
- } else {
- webView.addJavascriptInterface(object {
- @JavascriptInterface
- fun postMessage(json: String?) {
- onMessageReceived(json)
+ WebViewCompat.WebMessageListener { _, message, _, _, _ ->
+ onMessageReceived(message.data)
}
- }, LISTENER_NAME)
+ )
}
}
diff --git a/features/call/impl/src/main/res/values-ca/translations.xml b/features/call/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..92c518dbee
--- /dev/null
+++ b/features/call/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Trucada en curs"
+ "Toca per tornar a la trucada"
+ "☎️ Trucada en curs"
+ "Element Call entrant"
+
diff --git a/features/call/impl/src/main/res/values-et/translations.xml b/features/call/impl/src/main/res/values-et/translations.xml
index 16b72b8b97..fa0415a5f5 100644
--- a/features/call/impl/src/main/res/values-et/translations.xml
+++ b/features/call/impl/src/main/res/values-et/translations.xml
@@ -4,5 +4,5 @@
"Kõne juurde naasmiseks klõpsa""☎️ Kõne on pooleli""Element Call ei võimalda selles Androidi versioonis Bluetoothi heliseadmete kasutamist. Palun vali mõni muu heliseade."
- "Sissetulev Element Calli kõne"
+ "Saabuv Element Calli kõne"
diff --git a/features/call/impl/src/main/res/values-zh/translations.xml b/features/call/impl/src/main/res/values-zh/translations.xml
index 6192568a61..7a81fde819 100644
--- a/features/call/impl/src/main/res/values-zh/translations.xml
+++ b/features/call/impl/src/main/res/values-zh/translations.xml
@@ -1,8 +1,8 @@
"通话进行中"
- "点按即可返回通话"
+ "点击以返回通话""☎️ 通话中"
- "Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其他音频设备。"
- "Element 来电"
+ "Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其它音频设备。"
+ "Element Call 来电"
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
index 85cec8c586..f21447cc85 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt
@@ -11,7 +11,7 @@ package io.element.android.features.call
import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity
@@ -37,7 +37,7 @@ class DefaultElementCallEntryPointTest {
@Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint()
- entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
+ entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@@ -53,7 +53,7 @@ class DefaultElementCallEntryPointTest {
val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = "roomName",
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
index c087fa3c35..c3d7fdf17b 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
@@ -8,11 +8,9 @@
package io.element.android.features.call.impl.pip
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -20,9 +18,7 @@ class PictureInPicturePresenterTest {
@Test
fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse()
}
@@ -35,9 +31,7 @@ class PictureInPicturePresenterTest {
supportPip = true,
pipView = FakePipView(setPipParamsResult = { }),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue()
}
@@ -53,18 +47,16 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult,
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse()
- initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+ initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce()
- initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
+ initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip
- initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
+ initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
}
@@ -80,12 +72,10 @@ class PictureInPicturePresenterTest {
handUpResult = handUpResult
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
- initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false })))
- initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+ initialState.eventSink(PictureInPictureEvent.SetPipController(FakePipController(canEnterPipResult = { false })))
+ initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
handUpResult.assertions().isCalledOnce()
}
}
@@ -102,12 +92,10 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult
),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
initialState.eventSink(
- PictureInPictureEvents.SetPipController(
+ PictureInPictureEvent.SetPipController(
FakePipController(
canEnterPipResult = { true },
enterPipResult = enterPipResult,
@@ -115,16 +103,16 @@ class PictureInPicturePresenterTest {
)
)
)
- initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+ initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce()
enterPipResult.assertions().isNeverCalled()
- initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
+ initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue()
enterPipResult.assertions().isCalledOnce()
// User stops pip
exitPipResult.assertions().isNeverCalled()
- initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
+ initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse()
exitPipResult.assertions().isCalledOnce()
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt
new file mode 100644
index 0000000000..f0cdd44082
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.call.ui
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.call.api.CallData
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import org.junit.Test
+
+class CallDataTest {
+ @Test
+ fun `RoomCall stringification does not contain the URL`() {
+ assertThat(CallData(A_SESSION_ID, A_ROOM_ID, false).toString())
+ .isEqualTo("CallData(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID, isAudioCall=false)")
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt
new file mode 100644
index 0000000000..f07f7039d3
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.call.ui
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.call.impl.ui.CallScreenBackPressAction
+import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
+import org.junit.Test
+
+class CallScreenBackPressPolicyTest {
+ @Test
+ fun `resolve returns dispatch escape when a web view is available and native button is pressed`() {
+ val result = CallScreenBackPressPolicy.resolve(
+ supportPip = false,
+ hasWebView = true,
+ fromNative = true,
+ )
+
+ assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
+ }
+
+ @Test
+ fun `resolve dispatch escape when there is a web view and pip is supported on native button press`() {
+ val result = CallScreenBackPressPolicy.resolve(
+ supportPip = true,
+ hasWebView = true,
+ fromNative = true,
+ )
+
+ assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
+ }
+
+ @Test
+ fun `resolve returns hangup when there is no web view and pip is not supported from native button`() {
+ val result = CallScreenBackPressPolicy.resolve(
+ supportPip = false,
+ hasWebView = false,
+ fromNative = true,
+ )
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `resolve returns hangup when there is no web view even though pip is supported from native button`() {
+ val result = CallScreenBackPressPolicy.resolve(
+ supportPip = true,
+ hasWebView = false,
+ fromNative = true,
+ )
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `resolve goes to pip if its not from native but from the webview`() {
+ val result = CallScreenBackPressPolicy.resolve(
+ supportPip = true,
+ hasWebView = true,
+ fromNative = false,
+ )
+
+ assertThat(result).isEqualTo(CallScreenBackPressAction.EnterPictureInPicture)
+ }
+ @Test
+ fun `resolve hangs up if its not from native but from the webview and pip is not supported`() {
+ val result = CallScreenBackPressPolicy.resolve(
+ supportPip = false,
+ hasWebView = true,
+ fromNative = false,
+ )
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `invalid cases (event comes from webview but there is now webview) all result in hangup`() {
+ val withPipSupport = CallScreenBackPressPolicy.resolve(
+ supportPip = true,
+ hasWebView = false,
+ fromNative = false,
+ )
+ assertThat(withPipSupport).isNull()
+ val withOutPipSupport = CallScreenBackPressPolicy.resolve(
+ supportPip = false,
+ hasWebView = false,
+ fromNative = false,
+ )
+ assertThat(withOutPipSupport).isNull()
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
index b6b0120451..276e6670f1 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
@@ -13,8 +13,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.MobileScreen
-import io.element.android.features.call.api.CallType
-import io.element.android.features.call.impl.ui.CallScreenEvents
+import io.element.android.features.call.api.CallData
+import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.WidgetMessageSerializer
@@ -39,6 +39,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
@@ -59,46 +60,19 @@ class CallScreenPresenterTest {
val warmUpRule = WarmUpRule()
@Test
- fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
- val analyticsLambda = lambdaRecorder {}
- val joinedCallLambda = lambdaRecorder {}
- val presenter = createCallScreenPresenter(
- callType = CallType.ExternalUrl("https://call.element.io"),
- screenTracker = FakeScreenTracker(analyticsLambda),
- activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- // Wait until the URL is loaded
- advanceTimeBy(1.seconds)
- skipItems(2)
- val initialState = awaitItem()
- assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
- assertThat(initialState.webViewError).isNull()
- assertThat(initialState.isInWidgetMode).isFalse()
- assertThat(initialState.isCallActive).isFalse()
- analyticsLambda.assertions().isNeverCalled()
- joinedCallLambda.assertions().isCalledOnce()
- }
- }
-
- @Test
- fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest {
+ fun `present - with CallData sets call as active, loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder {}
- val joinedCallLambda = lambdaRecorder {}
+ val joinedCallLambda = lambdaRecorder {}
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(1)
@@ -107,7 +81,6 @@ class CallScreenPresenterTest {
val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse()
- assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
@@ -123,19 +96,17 @@ class CallScreenPresenterTest {
fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
val initialState = awaitItem()
- initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+ initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
// And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message")
@@ -154,24 +125,22 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
- initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+ initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
- initialState.eventSink(CallScreenEvents.Hangup)
+ initialState.eventSink(CallScreenEvent.Hangup)
// Let background coroutines run and the widget drive be received
runCurrent()
@@ -188,22 +157,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
- initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+ initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
@@ -223,22 +190,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
- initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+ initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage(
"""
{
@@ -260,22 +225,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {},
)
val messageInterceptor = FakeWidgetMessageInterceptor()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse()
- initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+ initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
skipItems(2)
// Wait for the timeout to trigger
@@ -300,7 +263,7 @@ class CallScreenPresenterTest {
val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter(
- callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false),
+ callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver,
navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@@ -338,53 +301,8 @@ class CallScreenPresenterTest {
}
}
- @Test
- fun `present - error from WebView are updating the state`() = runTest {
- val presenter = createCallScreenPresenter(
- callType = CallType.ExternalUrl("https://call.element.io"),
- activeCallManager = FakeActiveCallManager(),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- // Wait until the URL is loaded
- advanceTimeBy(1.seconds)
- skipItems(2)
- val initialState = awaitItem()
- initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
- val finalState = awaitItem()
- assertThat(finalState.webViewError).isEqualTo("A Webview error")
- }
- }
-
- @Test
- fun `present - error from WebView are ignored if Element Call is loaded`() = runTest {
- val presenter = createCallScreenPresenter(
- callType = CallType.ExternalUrl("https://call.element.io"),
- activeCallManager = FakeActiveCallManager(),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- // Wait until the URL is loaded
- skipItems(1)
- val initialState = awaitItem()
-
- val messageInterceptor = FakeWidgetMessageInterceptor()
- initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
- // Emit a message
- messageInterceptor.givenInterceptedMessage("A message")
- // WebView emits an error, but it will be ignored
- initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
- val finalState = awaitItem()
- assertThat(finalState.webViewError).isNull()
-
- cancelAndIgnoreRemainingEvents()
- }
- }
-
private fun TestScope.createCallScreenPresenter(
- callType: CallType,
+ callData: CallData,
navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
@@ -401,7 +319,7 @@ class CallScreenPresenterTest {
}
val clock = SystemClock { 0 }
return CallScreenPresenter(
- callType = callType,
+ callData = callData,
navigator = navigator,
callWidgetProvider = widgetProvider,
userAgentProvider = userAgentProvider,
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt
new file mode 100644
index 0000000000..99aaee6f39
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt
@@ -0,0 +1,151 @@
+/*
+ * 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.call.ui
+
+import android.view.KeyEvent
+import android.webkit.WebView
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.element.android.features.call.impl.pip.PictureInPictureEvent
+import io.element.android.features.call.impl.pip.PictureInPictureState
+import io.element.android.features.call.impl.pip.aPictureInPictureState
+import io.element.android.features.call.impl.ui.CallScreenEvent
+import io.element.android.features.call.impl.ui.CallScreenState
+import io.element.android.features.call.impl.ui.CallScreenView
+import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
+import io.element.android.features.call.impl.ui.aCallScreenState
+import io.element.android.tests.testutils.EventsRecorder
+import io.element.android.tests.testutils.pressBackKey
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+import org.robolectric.annotation.Resetter
+import org.robolectric.shadows.ShadowWebView
+
+@OptIn(ExperimentalTestApi::class)
+@RunWith(AndroidJUnit4::class)
+class CallScreenViewTest {
+ @Test
+ fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
+ val callEvents = EventsRecorder()
+
+ setCallScreenView(
+ state = aCallScreenState(eventSink = callEvents),
+ useInspectionMode = true,
+ )
+
+ pressBackKey()
+
+ callEvents.assertEmpty()
+ }
+
+ @Config(shadows = [RecordingShadowWebView::class])
+ @Test
+ fun `pressing back key dispatches escape key events to web view when pip is unsupported`() = runAndroidComposeUiTest {
+ setCallScreenView(
+ state = aCallScreenState(),
+ useInspectionMode = false,
+ pipState = aPictureInPictureState(supportPip = false),
+ )
+
+ pressBackKey()
+
+ val dispatchedEvents = RecordingShadowWebView.dispatchedEvents
+ assertEquals(2, dispatchedEvents.size)
+ assertEquals(KeyEvent.ACTION_DOWN, dispatchedEvents[0].action)
+ assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[0].keyCode)
+ assertEquals(KeyEvent.ACTION_UP, dispatchedEvents[1].action)
+ assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[1].keyCode)
+ }
+
+ @Config(shadows = [RecordingShadowWebView::class])
+ @Test
+ fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest {
+ val pipEvents = EventsRecorder()
+
+ setCallScreenView(
+ state = aCallScreenState(),
+ useInspectionMode = false,
+ pipState = aPictureInPictureState(
+ supportPip = true,
+ eventSink = pipEvents,
+ ),
+ )
+
+ runOnIdle {
+ RecordingShadowWebView.invokeJavascriptBackHandler()
+ }
+
+ pipEvents.assertSize(2)
+ pipEvents.assertTrue(0) { it is PictureInPictureEvent.SetPipController }
+ pipEvents.assertTrue(1) { it is PictureInPictureEvent.EnterPictureInPicture }
+ }
+}
+
+@OptIn(ExperimentalTestApi::class)
+private fun AndroidComposeUiTest.setCallScreenView(
+ state: CallScreenState,
+ useInspectionMode: Boolean,
+ pipState: PictureInPictureState = aPictureInPictureState(supportPip = false),
+) {
+ setContent {
+ // Inspection mode disables AndroidView creation; keep it configurable per test.
+ CompositionLocalProvider(LocalInspectionMode provides useInspectionMode) {
+ CallScreenView(
+ state = state,
+ pipState = pipState,
+ onConsoleMessage = {},
+ requestPermissions = { _, _ -> },
+ )
+ }
+ }
+}
+
+@Implements(WebView::class)
+internal class RecordingShadowWebView : ShadowWebView() {
+ companion object {
+ val dispatchedEvents = mutableListOf()
+ private var backHandlerJavascriptInterface: JavascriptBackHandlerBridge? = null
+
+ @Resetter
+ @JvmStatic
+ @Suppress("unused")
+ fun resetRecordedEvents() {
+ dispatchedEvents.clear()
+ backHandlerJavascriptInterface = null
+ }
+
+ fun invokeJavascriptBackHandler() {
+ val backHandler = checkNotNull(backHandlerJavascriptInterface) { "Expected backHandler JavaScript interface to be registered" }
+ backHandler.onBackPressed()
+ }
+ }
+
+ @Implementation
+ protected override fun addJavascriptInterface(`object`: Any, name: String) {
+ super.addJavascriptInterface(`object`, name)
+ if (name == "backHandler") {
+ backHandlerJavascriptInterface = `object` as? JavascriptBackHandlerBridge
+ }
+ }
+
+ @Implementation
+ @Suppress("unused")
+ fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ dispatchedEvents += KeyEvent(event)
+ return false
+ }
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt
deleted file mode 100644
index c83408bd3b..0000000000
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallTypeTest.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.features.call.ui
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.call.api.CallType
-import io.element.android.features.call.impl.ui.getSessionId
-import io.element.android.libraries.matrix.test.A_ROOM_ID
-import io.element.android.libraries.matrix.test.A_SESSION_ID
-import org.junit.Test
-
-class CallTypeTest {
- @Test
- fun `getSessionId returns null for ExternalUrl`() {
- assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull()
- }
-
- @Test
- fun `getSessionId returns the sessionId for RoomCall`() {
- assertThat(
- CallType.RoomCall(
- sessionId = A_SESSION_ID,
- roomId = A_ROOM_ID,
- isAudioCall = false,
- ).getSessionId()
- ).isEqualTo(A_SESSION_ID)
- }
-
- @Test
- fun `ExternalUrl stringification does not contain the URL`() {
- assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl")
- }
-
- @Test
- fun `RoomCall stringification does not contain the URL`() {
- assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false).toString())
- .isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID, isAudioCall=false)")
- }
-}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
deleted file mode 100644
index 43f7f931f1..0000000000
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
+++ /dev/null
@@ -1,226 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2023-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.call.utils
-
-import com.google.common.truth.Truth.assertThat
-import io.element.android.features.call.impl.utils.CallIntentDataParser
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import java.net.URLEncoder
-
-@RunWith(RobolectricTestRunner::class)
-class CallIntentDataParserTest {
- private val callIntentDataParser = CallIntentDataParser()
-
- @Test
- fun `a null data returns null`() {
- val url: String? = null
- assertThat(callIntentDataParser.parse(url)).isNull()
- }
-
- @Test
- fun `empty data returns null`() {
- doTest("", null)
- }
-
- @Test
- fun `invalid data returns null`() {
- doTest("!", null)
- }
-
- @Test
- fun `data with no scheme returns null`() {
- doTest("test", null)
- }
-
- @Test
- fun `Element Call http urls returns null`() {
- doTest("http://call.element.io", null)
- doTest("http://call.element.io/some-actual-call?with=parameters", null)
- }
-
- @Test
- fun `Element Call urls with unknown host returns null`() {
- // Check valid host first, should not return null
- doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true")
- // Unknown host should return null
- doTest("https://unknown.io", null)
- doTest("https://call.unknown.io", null)
- doTest("https://call.element.com", null)
- doTest("https://call.element.io.tld", null)
- }
-
- @Test
- fun `Element Call urls will be returned as is`() {
- doTest(
- url = "https://call.element.io",
- expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with url param gets url extracted`() {
- doTest(
- url = VALID_CALL_URL_WITH_PARAM,
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `HTTP and HTTPS urls that don't come from EC return null`() {
- doTest("http://app.element.io", null)
- doTest("https://app.element.io", null)
- doTest("http://", null)
- doTest("https://", null)
- }
-
- @Test
- fun `Element Call url with no url returns null`() {
- val embeddedUrl = VALID_CALL_URL_WITH_PARAM
- val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
- val url = "io.element.call:/?no_url=$encodedUrl"
- assertThat(callIntentDataParser.parse(url)).isNull()
- }
-
- @Test
- fun `element scheme with no call host returns null`() {
- val embeddedUrl = VALID_CALL_URL_WITH_PARAM
- val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
- val url = "element://no-call?url=$encodedUrl"
- assertThat(callIntentDataParser.parse(url)).isNull()
- }
-
- @Test
- fun `element scheme with no data returns null`() {
- val url = "element://call?url="
- assertThat(callIntentDataParser.parse(url)).isNull()
- }
-
- @Test
- fun `Element Call url with no data returns null`() {
- val url = "io.element.call:/?url="
- assertThat(callIntentDataParser.parse(url)).isNull()
- }
-
- @Test
- fun `element invalid scheme returns null`() {
- val embeddedUrl = VALID_CALL_URL_WITH_PARAM
- val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
- val url = "bad.scheme:/?url=$encodedUrl"
- assertThat(callIntentDataParser.parse(url)).isNull()
- }
-
- @Test
- fun `Element Call url with url extra param appPrompt gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM&appPrompt=true",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
- )
- }
-
- @Test
- fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true&otherParam=maybe",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
- )
- }
-
- @Test
- fun `Element Call url with url extra param confineToRoom gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM&confineToRoom=false",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
- )
- }
-
- @Test
- fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false&otherParam=maybe",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
- )
- }
-
- @Test
- fun `Element Call url with url fragment gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#fragment",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with url fragment with params gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with url fragment with other params gets url extracted`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with empty fragment`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
- )
- }
-
- @Test
- fun `Element Call url with empty fragment query`() {
- doTest(
- url = "$VALID_CALL_URL_WITH_PARAM#?",
- expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
- )
- }
-
- private fun doTest(url: String, expectedResult: String?) {
- // Test direct parsing
- assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
-
- // Test embedded url, scheme 1
- val encodedUrl = URLEncoder.encode(url, "utf-8")
- val urlScheme1 = "element://call?url=$encodedUrl"
- assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
-
- // Test embedded url, scheme 2
- val urlScheme2 = "io.element.call:/?url=$encodedUrl"
- assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
- }
-
- companion object {
- const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
- const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
- }
-}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
index f9f6206ec7..3712904b03 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
@@ -13,7 +13,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
@@ -77,7 +77,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,
@@ -104,7 +104,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = true,
@@ -132,7 +132,7 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall)
- assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2)
+ assertThat(manager.activeCall.value?.callData?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1)
@@ -178,7 +178,7 @@ class DefaultActiveCallManagerTest {
}
@Test
- fun `hangUpCall - removes existing call if the CallType matches`() = runTest {
+ fun `hangUpCall - removes existing call if the CallData matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@@ -188,7 +188,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
- manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false))
+ manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
@@ -215,7 +215,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData)
- manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false))
+ manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
coVerify {
room.declineCall(notificationEventId = notificationData.eventId)
@@ -242,7 +242,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
// Do not register the incoming call, so the manager doesn't know about it
manager.hangUpCall(
- callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false),
+ callData = CallData(notificationData.sessionId, notificationData.roomId, false),
notificationData = notificationData,
)
coVerify {
@@ -320,7 +320,7 @@ class DefaultActiveCallManagerTest {
}
@Test
- fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest {
+ fun `hangUpCall - does nothing if the CallData doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@@ -329,7 +329,13 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
- manager.hangUpCall(CallType.ExternalUrl("https://example.com"))
+ manager.hangUpCall(
+ CallData(
+ sessionId = A_SESSION_ID,
+ roomId = A_ROOM_ID_2,
+ isAudioCall = true,
+ )
+ )
assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
@@ -344,10 +350,10 @@ class DefaultActiveCallManagerTest {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull()
- manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, true))
+ manager.joinedCall(CallData(A_SESSION_ID, A_ROOM_ID, true))
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
isAudioCall = true,
@@ -450,7 +456,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
index 2d0e126ab5..c2c38284a9 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt
@@ -8,7 +8,7 @@
package io.element.android.features.call.utils
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager
@@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
- var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> },
- var joinedCallResult: (CallType) -> Unit = {},
+ var hangUpCallResult: (CallData, CallNotificationData?) -> Unit = { _, _ -> },
+ var joinedCallResult: (CallData) -> Unit = {},
) : ActiveCallManager {
override val activeCall = MutableStateFlow(null)
@@ -26,12 +26,12 @@ class FakeActiveCallManager(
registerIncomingCallResult(notificationData)
}
- override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask {
- hangUpCallResult(callType, notificationData)
+ override suspend fun hangUpCall(callData: CallData, notificationData: CallNotificationData?) = simulateLongTask {
+ hangUpCallResult(callData, notificationData)
}
- override suspend fun joinedCall(callType: CallType) = simulateLongTask {
- joinedCallResult(callType)
+ override suspend fun joinedCall(callData: CallData) = simulateLongTask {
+ joinedCallResult(callData)
}
fun setActiveCall(value: ActiveCall?) {
diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
index fdf3ca566b..13b61feacb 100644
--- a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
+++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt
@@ -8,16 +8,16 @@
package io.element.android.features.call.test
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallEntryPoint(
- var startCallResult: (CallType) -> Unit = { lambdaError() },
+ var startCallResult: (CallData) -> Unit = { lambdaError() },
var handleIncomingCallResult: (
- CallType.RoomCall,
+ CallData,
EventId,
UserId,
String?,
@@ -27,12 +27,12 @@ class FakeElementCallEntryPoint(
String?,
) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
) : ElementCallEntryPoint {
- override fun startCall(callType: CallType) {
- startCallResult(callType)
+ override fun startCall(callData: CallData) {
+ startCallResult(callData)
}
override suspend fun handleIncomingCall(
- callType: CallType.RoomCall,
+ callData: CallData,
eventId: EventId,
senderId: UserId,
roomName: String?,
@@ -44,7 +44,7 @@ class FakeElementCallEntryPoint(
textContent: String?,
) {
handleIncomingCallResult(
- callType,
+ callData,
eventId,
senderId,
roomName,
diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt
index 6b7b66b897..1480e57334 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt
@@ -95,7 +95,8 @@ internal fun SelectParentSpaceOptions(
sheetState.hide(coroutineScope) {
displaySelectSpaceBottomSheet = false
}
- }
+ },
+ scrollable = false,
) {
SelectParentSpaceBottomSheet(
spaces = spaces,
diff --git a/features/createroom/impl/src/main/res/values-ca/translations.xml b/features/createroom/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..f9e1dea0fc
--- /dev/null
+++ b/features/createroom/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,16 @@
+
+
+ "Sala nova"
+ "Convida persones"
+ "S\'ha produït un error en crear la sala"
+ "Només s\'hi poden unir les persones convidades."
+ "Tothom pot trobar aquesta sala.
+Pots canviar-ho en qualsevol moment a la configuració de sala."
+ "Qualsevol persona pot sol·licitar unir-s\'hi però un administrador o moderador l\'haurà d\'acceptar"
+ "Permet sol·licituds d\'unió"
+ "Tothom pot unir-s\'hi."
+ "És necessària una adreça perquè sigui visible al directori públic."
+ "Adreça"
+ "Visibilitat de sala"
+ "Tema (opcional)"
+
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 66c4f08b70..ab72755dcf 100644
--- a/features/createroom/impl/src/main/res/values-da/translations.xml
+++ b/features/createroom/impl/src/main/res/values-da/translations.xml
@@ -19,7 +19,7 @@ Du kan ændre dette når som helst i rummets indstillinger.""Anmod om at deltage""Kun inviterede brugere kan deltage.""Privat"
- "Alle kan deltage i dette rum"
+ "Alle kan deltage.""Offentlig""Alle i %1$s kan deltage.""Standard"
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 d1f5bfd283..c3f42ca287 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -8,15 +8,19 @@
"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.""Jeder kann beitreten."
+ "Öffentlich""Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren.""Anfrage zum Beitritt zulassen""Jeder in %1$s kann beitreten, aber alle anderen müssen den Beitritt anfragen.""Beitritt anfragen""Nur eingeladene Personen können beitreten."
+ "Privat""Jeder darf diesem Chat beitreten."
+ "Öffentlich""Jeder in %1$s kann beitreten.""Standard""Wer hat Zugang"
@@ -24,7 +28,8 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern.""Adresse"" Sichtbarkeit des Chats""(kein Space)"
- "Home"
+ "Nicht zu einem Space hinzufügen"
+ "Kein Space ausgewählt""Space hinzufügen""Thema (optional)""Beschreibung hinzufügen…"
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 27542ccc19..821d55af67 100644
--- a/features/createroom/impl/src/main/res/values-fa/translations.xml
+++ b/features/createroom/impl/src/main/res/values-fa/translations.xml
@@ -3,7 +3,7 @@
"اتاق جدید""دعوت افراد""هنگام ایجاد اتاق خطایی رخ داد"
- "تنها افراد دعوت شده میتوانند به این اتاق دسترسی داشته باشند. همهٔ پیامها رمزنگاری سرتاسری شدهاند."
+ "تنها افراد دعوت شده میتوانند بپیوندند.""هرکسی میتواند اتاق را بیابد.
میتوانید بعداً در تظیمات اتاق عوضش کنید.""درخواست دعوت"
diff --git a/features/createroom/impl/src/main/res/values-hr/translations.xml b/features/createroom/impl/src/main/res/values-hr/translations.xml
index 81979e3f84..17336bebf2 100644
--- a/features/createroom/impl/src/main/res/values-hr/translations.xml
+++ b/features/createroom/impl/src/main/res/values-hr/translations.xml
@@ -3,15 +3,34 @@
"Nova soba""Pozovi osobe""Došlo je do pogreške prilikom stvaranja sobe"
+ "Prostor nije moguće stvoriti zbog nepoznate pogreške. Pokušajte ponovno kasnije."
+ "Dodaj ime…""Nova soba"
- "Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."
+ "Novi prostor"
+ "Samo pozvane osobe mogu se pridružiti."
+ "Privatno""Svatko može pronaći ovu sobu.
To možete u svakom trenutku promijeniti u postavkama sobe."
+ "Svatko se može pridružiti."
+ "Javno""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"
+ "Dopusti traženje pridruživanja"
+ "Svatko u %1$s može se pridružiti, ali svi ostali moraju zatražiti pristup."
+ "Zatraži pridruživanje"
+ "Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."
+ "Privatno"
+ "Svatko se može pridružiti."
+ "Javno"
+ "Svatko u %1$s može se pridružiti."
+ "Standard"
+ "Tko ima pristup""Da bi ova soba bila vidljiva u javnom direktoriju soba, trebat će vam adresa sobe."
- "Adresa sobe"
+ "Adresa""Vidljivost sobe"
+ "(bez razmaka)"
+ "Ne dodavaj u prostor"
+ "Nije odabran nijedan prostor"
+ "Dodaj u prostor""Tema (neobavezno)"
+ "Dodaj opis…"
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 0164225ea2..b602cb13d8 100644
--- a/features/createroom/impl/src/main/res/values-pl/translations.xml
+++ b/features/createroom/impl/src/main/res/values-pl/translations.xml
@@ -3,14 +3,34 @@
"Nowy pokój""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."
+ "Nie udało się utworzyć przestrzeni z powodu nieznanego błędu. Spróbuj ponownie później."
+ "Dodaj nazwę…"
+ "Nowy pokój"
+ "Nowa przestrzeń"
+ "Dołączyć mogą tylko zaproszone osoby."
+ "Prywatny""Każdy może znaleźć ten pokój.
Możesz to zmienić w ustawieniach 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"
- "Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju."
- "Adres pokoju"
+ "Każdy może dołączyć."
+ "Publiczny"
+ "Każdy może poprosić o dołączenie, ale administrator lub moderator musi to zaakceptować."
+ "Zezwól na prośbę o dołączenie"
+ "Każdy w %1$s może dołączyć, ale wszyscy pozostali muszą poprosić o dostęp."
+ "Poproś o dołączenie"
+ "Dołączyć mogą tylko zaproszone osoby."
+ "Prywatny"
+ "Każdy może dołączyć."
+ "Publiczny"
+ "Każdy w %1$s może dołączyć."
+ "Standardowy"
+ "Kto ma dostęp"
+ "Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, potrzebny jest adres pokoju."
+ "Adres""Widoczność pomieszczenia"
+ "(brak przestrzeni)"
+ "Nie dodawaj do przestrzeni"
+ "Nie wybrano przestrzeni"
+ "Dodaj do przestrzeni""Temat (opcjonalnie)"
+ "Dodaj opis…"
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 a46fd1a1c4..33a5351877 100644
--- a/features/createroom/impl/src/main/res/values-ro/translations.xml
+++ b/features/createroom/impl/src/main/res/values-ro/translations.xml
@@ -3,14 +3,34 @@
"Cameră nouă""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."
+ "Spațiul nu a putut fi creat din cauza unei erori necunoscute. Încercați din nou mai târziu."
+ "Adăugați un nume…"
+ "Cameră nouă"
+ "Spațiu nou"
+ "Doar persoanele invitate se pot alătura."
+ "Privat""Oricine poate găsi această cameră.
Puteți modifica acest lucru oricând în setări."
+ "Oricine se poate alătura."
+ "Public""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"
+ "Permite solicitarea de alăturare"
+ "Oricine din %1$s se poate alătura, dar oricine altcineva trebuie să solicite acces."
+ "Solicitați să vă alăturați"
+ "Doar persoanele invitate se pot alătura."
+ "Privat""Oricine se poate alătura acestei camere"
+ "Public"
+ "Oricine din %1$s se poate alătura."
+ "Standard"
+ "Cine are acces""Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră."
- "Adresa camerei"
+ "Adresă""Vizibilitatea camerei"
+ "(nicun spațiu)"
+ "Nu adăugați la un spațiu"
+ "Niciun spațiu selectat"
+ "Adăugați la spațiu""Subiect (opțional)"
+ "Adăugați o descriere…"
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 d01da0dc35..a40a2b21a5 100644
--- a/features/createroom/impl/src/main/res/values-uk/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uk/translations.xml
@@ -3,22 +3,32 @@
"Нова кімната""Запросити людей""Під час створення кімнати сталася помилка"
+ "Простір не вдалося створити через невідому помилку. Спробуйте ще раз пізніше.""Додати назву…""Нова кімната""Новий простір""Можуть приєднатися лише запрошені люди."
+ "Приватний""Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати.""Приєднатися може будь-хто."
+ "Публічний""Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит.""Дозволити запит на приєднання"
+ "Будь-хто з %1$s може приєднатися, але всі інші повинні подати запит на доступ."
+ "Запит на приєднання""Приєднатися можуть лише запрошені особи."
+ "Приватний""Приєднатися може будь-хто."
+ "Публічний""Приєднатися може будь-хто з %1$s."
+ "Стандартний""Хто має доступ""Вам знадобиться адреса, щоб зробити її видимою в загальнодоступному каталозі.""Адреса""Видимість кімнати"
+ "(без пробілу)"
+ "Не додавати до простору""Головна""Додати до простору""Тема (необов\'язково)"
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 98e246716d..88de696f64 100644
--- a/features/createroom/impl/src/main/res/values-uz/translations.xml
+++ b/features/createroom/impl/src/main/res/values-uz/translations.xml
@@ -3,14 +3,34 @@
"Yangi xona""Odamlarni taklif qiling""Xonani yaratishda xatolik yuz berdi"
+ "Noma’lum xatolik tufayli maydon yaratilmadi. Keyinroq qayta urining."
+ "Ism qo‘shish…"
+ "Yangi xona"
+ "Yangi maydon""Faqat taklif etilgan shaxslargina bu xonaga kira oladi. Barcha xabarlar boshdan-oxirigacha shifrlanadi."
+ "Maxfiy""Bu xonani har kim topishi mumkin.
Buni xona sozlamalaridan istalgan vaqtda oʻzgartirishingiz mumkin."
- "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"
- "Ushbu xona ommaviy xonalar ro‘yxatida ko‘rinishi uchun sizga xona manzili kerak bo‘ladi."
+ "Istalgan kishi qo‘shilishi mumkin"
+ "Ommaviy"
+ "Istalgan kishi qo‘shilishni so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak."
+ "Qo‘shilish uchun ruxsat so‘rash"
+ "%1$s ichidagi har kim kirishi mumkin, lekin boshqalar ruxsat so‘rashi kerak."
+ "Qo‘shilish uchun so‘rash"
+ "Faqat taklif qilinganlar qo‘shilishi mumkin."
+ "Maxfiy"
+ "Istalgan kishi qo‘shilishi mumkin"
+ "Ommaviy"
+ "%1$s ichidagi har kim qo‘shilishi mumkin."
+ "Standart"
+ "Kimning kirish huquqi bor"
+ "Ommaviy katalogda ko‘rinadigan qilish uchun manzil kerak bo‘ladi.""Xona manzili""Xonaning ko‘rinishi"
+ "(maydon yo‘q)"
+ "Maydonga kiritilmasin"
+ "Hech qanday maydon tanlanmagan"
+ "Maydonga qo‘shish""Mavzu (ixtiyoriy)"
+ "Tavsif kiritish…"
diff --git a/features/createroom/impl/src/main/res/values-vi/translations.xml b/features/createroom/impl/src/main/res/values-vi/translations.xml
index b10d15e077..cde672d7de 100644
--- a/features/createroom/impl/src/main/res/values-vi/translations.xml
+++ b/features/createroom/impl/src/main/res/values-vi/translations.xml
@@ -4,8 +4,12 @@
"Mời ai đó""Đã xảy ra lỗi khi tạo phòng.""Chỉ những người được mời mới có thể tham gia."
+ "Riêng tư""Bất kỳ ai cũng có thể tìm thấy phòng này.
Bạn có thể thay đổi cài đặt phòng bất cứ lúc nào."
+ "Công cộng"
+ "Riêng tư"
+ "Công cộng""Chủ đề (tùy chọn)""Thêm mô tả…"
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 05495a0594..0899f065d7 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
@@ -3,14 +3,34 @@
"建立聊天室""邀請夥伴""建立聊天室時發生錯誤"
- "僅被邀請的人才能存取此聊天室。所有訊息均會端到端加密。"
+ "因為未知錯誤,無法建立空間。請稍後再試。"
+ "新增名稱……"
+ "新聊天室"
+ "新空間"
+ "僅被邀請的人才能加入。"
+ "私人""任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"
- "任何人都可以要求加入聊天室,但管理員或版主必須接受該請求"
- "要求加入"
- "任何人都可以加入此聊天室"
- "為了讓此聊天室在公開聊天室目錄中可見,您需要聊天室地址。"
- "聊天室地址"
+ "任何人都可以加入。"
+ "公開"
+ "任何人都可以要求加入,但管理員或版主必須接受該請求"
+ "允許要求加入"
+ "任何在 %1$s 中的人都可以加入,但其他人就必須申請存取權。"
+ "要求加入"
+ "僅被邀請的人才可以加入。"
+ "私人"
+ "任何人都可以加入"
+ "公開"
+ "在 %1$s 中的任何人都可以加入。"
+ "標準"
+ "誰有權存取"
+ "您需要地址才能讓該資訊在公開目錄中顯示。"
+ "地址""聊天室能見度"
+ "(沒有空間)"
+ "不要新增至空間"
+ "未選取空間"
+ "新增至空間""主題(非必填)"
+ "新增描述……"
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 46d9654fbf..1ba1036634 100644
--- a/features/createroom/impl/src/main/res/values-zh/translations.xml
+++ b/features/createroom/impl/src/main/res/values-zh/translations.xml
@@ -1,36 +1,36 @@
- "新聊天室"
- "邀请朋友"
- "创建聊天室时出错"
+ "新房间"
+ "邀请人员"
+ "创建房间时出错""由于未知错误,空间创建失败。请稍后再试。""添加名称…"
- "新聊天室"
+ "新房间""新空间"
- "仅限受邀者加入。"
+ "仅限受邀人员加入。""私密"
- "任何人都能找到此聊天室。
-你可以随时在聊天室设置中更改。"
- "任何人都可以找到并加入"
+ "任何人都能找到此房间。
+你可以随时在房间设置中更改。"
+ "任何人都可以加入""公共"
- "任何人都可申请加入,但需由管理员或版主批准请求。"
- "请求加入"
- "%1$s 中的任何人都可加入,但其他人必须申请访问权限。"
+ "任何人都可申请加入,但需由管理员或协管员批准申请。"
+ "申请加入"
+ "%1$s 中的任何人都可以加入,但其他人必须申请访问。""申请加入"
- "仅限受邀者加入。"
+ "仅限受邀人员加入。""私密""任何人都可以加入。""公共"
- "%1$s 中的任何人可加入。"
+ "%1$s 中的任何人都可以加入。""标准""谁有权访问此房间"
- "要使该聊天室在公共目录中可见,您需要一个聊天室地址。"
+ "要使该房间在公共目录中可见,你需要一个地址。""地址""房间可见性""(无空间)"
- "请勿添加至空间"
+ "不要添加到空间""未选择空间"
- "添加至空间"
+ "添加到空间""主题(可选)""添加描述…"
diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt
index fcedcb2367..b9d7eb6e7d 100644
--- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt
+++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt
@@ -301,7 +301,9 @@ class ConfigureRoomPresenterTest {
roomName = 0,
roomAvatar = 0,
roomTopic = 0,
- spaceChild = 0
+ spaceChild = 0,
+ beacon = 0,
+ beaconInfo = 0,
),
users = persistentMapOf(),
)
diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt
index c0d625a45e..352efdfb92 100644
--- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt
+++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt
@@ -135,7 +135,7 @@ private fun ColumnScope.Buttons(
) {
val logoutAction = state.accountDeactivationAction
Button(
- text = stringResource(CommonStrings.action_deactivate),
+ text = stringResource(CommonStrings.action_delete),
showProgress = logoutAction is AsyncAction.Loading,
destructive = true,
enabled = state.submitEnabled,
diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt
index 905112a78d..ab9d87c543 100644
--- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt
+++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt
@@ -22,7 +22,7 @@ fun AccountDeactivationConfirmationDialog(
ConfirmationDialog(
title = stringResource(id = R.string.screen_deactivate_account_title),
content = stringResource(R.string.screen_deactivate_account_confirmation_dialog_content),
- submitText = stringResource(id = CommonStrings.action_deactivate),
+ submitText = stringResource(id = CommonStrings.action_delete),
onSubmitClick = onSubmitClick,
onDismiss = onDismiss,
destructiveSubmit = true,
diff --git a/features/deactivation/impl/src/main/res/values-bg/translations.xml b/features/deactivation/impl/src/main/res/values-bg/translations.xml
index 34ad5b4772..936e4726a5 100644
--- a/features/deactivation/impl/src/main/res/values-bg/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-bg/translations.xml
@@ -1,5 +1,4 @@
"Моля, потвърдете, че искате да деактивирате акаунта си. Това действие не може да бъде отменено."
- "Деактивиране на акаунта"
diff --git a/features/deactivation/impl/src/main/res/values-ca/translations.xml b/features/deactivation/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..c8ad39f7ae
--- /dev/null
+++ b/features/deactivation/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,13 @@
+
+
+ "Si us plau, confirma que vols desactivar el teu compte. Aquesta acció no es pot desfer."
+ "Elimina tots els meus missatges"
+ "Avís: els futurs usuaris podrien veure converses incompletes."
+ "La desactivació del compte és %1$s, implica:"
+ "irreversible"
+ "%1$s el compte (no podràs tornar a iniciar sessió i el teu ID no es podrà reutilitzar)."
+ "Desactiva permanentment"
+ "Se t\'eliminarà de totes les sales o xats."
+ "S\'eliminarà la informació del compte del nostre servidor d\'identitat."
+ "Els teus missatges continuaran sent visibles per als usuaris registrats, però no estaran disponibles per a usuaris nous o no registrats si decideixes eliminar-los."
+
diff --git a/features/deactivation/impl/src/main/res/values-cs/translations.xml b/features/deactivation/impl/src/main/res/values-cs/translations.xml
index e0f4fd14c7..13659e1a75 100644
--- a/features/deactivation/impl/src/main/res/values-cs/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-cs/translations.xml
@@ -1,14 +1,14 @@
- "Potvrďte prosím, že chcete svůj účet deaktivovat. Tuto akci nelze vrátit zpět."
+ "Potvrďte prosím, že chcete smazat svůj účet. Tuto akci nelze vrátit zpět.""Smazat všechny mé zprávy""Upozornění: Budoucí uživatelé mohou vidět neúplné konverzace."
- "Deaktivace vašeho účtu je %1$s, což způsobí:"
+ "Smazání účtu je %1$s, dojde k:""nezvratná""%1$s váš účet (nemůžete se znovu přihlásit a vaše ID nelze znovu použít).""Trvale zakázat""Odebere vás ze všech chatovacích místností.""Odstraní informace o vašem účtu z našeho serveru identit.""Vaše zprávy budou stále viditelné registrovaným uživatelům, ale nebudou dostupné novým ani neregistrovaným uživatelům, pokud se rozhodnete je smazat."
- "Deaktivovat účet"
+ "Smazat účet"
diff --git a/features/deactivation/impl/src/main/res/values-da/translations.xml b/features/deactivation/impl/src/main/res/values-da/translations.xml
index c6dcb1710a..f434547b8a 100644
--- a/features/deactivation/impl/src/main/res/values-da/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-da/translations.xml
@@ -1,14 +1,14 @@
- "Bekræft venligst, at du vil deaktivere din konto. Denne handling kan ikke fortrydes."
+ "Bekræft venligst, at du ønsker at slette din konto. Denne handling kan ikke fortrydes.""Slet alle mine beskeder""Advarsel: Fremtidige brugere kan muligvis se ufuldstændige samtaler."
- "Deaktivering af din konto er %1$s, det vil:"
+ "Sletning af din konto er %1$s, det vil:""irreversibel""%1$s din konto (du kan ikke logge ind igen, og dit ID kan ikke genbruges).""Permanent deaktivere""Fjerne dig fra alle samtaler""Slette dine kontooplysninger fra vores identitetsserver.""Dine beskeder vil stadig være synlige for registrerede brugere, men vil ikke være tilgængelige for nye eller uregistrerede brugere, hvis du vælger at slette dem."
- "Deaktiver konto"
+ "Slet konto"
diff --git a/features/deactivation/impl/src/main/res/values-de/translations.xml b/features/deactivation/impl/src/main/res/values-de/translations.xml
index 1aec7495a1..8430134d00 100644
--- a/features/deactivation/impl/src/main/res/values-de/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-de/translations.xml
@@ -1,14 +1,14 @@
- "Bitte bestätige, dass du dein Konto deaktivieren möchtest. Dies kann nicht rückgängig gemacht werden."
+ "Bitte bestätige, dass du dein Konto löschen möchtest. Diese Aktion kann nicht rückgängig gemacht werden.""Lösche alle meine Nachrichten""Warnung: Künftigen Nutzern werden möglicherweise unvollständige Konversationen angezeigt."
- "Dein Konto zu deaktivieren ist %1$s. Folgendes wird passieren:"
+ "Das Löschen deines Kontos ist %1$s. Es wird:""irreversibel""%1$s dein Konto (du kannst dich nicht erneut anmelden und deine ID kann nicht wiederverwendet werden).""Dauerhaft deaktivieren""Du wirst aus allen Chats entfernt.""Lösche deine Kontoinformationen von unserem Identitätsserver.""Deine Nachrichten werden für bereits registrierte Nutzer weiterhin sichtbar sein. Für neue oder unregistrierte Nutzer sind sie nicht verfügbar, wenn du sie löschen solltest."
- "Nutzerkonto deaktivieren"
+ "Konto löschen"
diff --git a/features/deactivation/impl/src/main/res/values-el/translations.xml b/features/deactivation/impl/src/main/res/values-el/translations.xml
index ac645f3063..b6a359abbf 100644
--- a/features/deactivation/impl/src/main/res/values-el/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-el/translations.xml
@@ -1,14 +1,14 @@
- "Παρακαλώ επιβεβαίωσε ότι θες να απενεργοποιήσεις τον λογαριασμό σου. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
+ "Επιβεβαιώστε ότι θέλετε να διαγράψετε τον λογαριασμό σας. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.""Διαγραφή όλων των μηνυμάτων μου""Προειδοποίηση: Οι μελλοντικοί χρήστες ενδέχεται να βλέπουν ελλιπείς συνομιλίες."
- "Η απενεργοποίηση του λογαριασμού σας είναι %1$s, θα:"
+ "Η διαγραφή του λογαριασμού σας είναι %1$s, και θα:""μη αναστρέψιμο""%1$s τον λογαριασμό σου (δεν μπορείς να συνδεθείς ξανά και το αναγνωριστικό σου δεν μπορεί να επαναχρησιμοποιηθεί).""Μόνιμη απενεργοποίηση""Αποχώρησή σας από όλες τις αίθουσες συνομιλίας.""Διαγράψει τα στοιχεία του λογαριασμού σου από τον διακομιστή ταυτότητάς μας.""Τα μηνύματά σου θα εξακολουθούν να είναι ορατά στους εγγεγραμμένους χρήστες, αλλά δεν θα είναι διαθέσιμα σε νέους ή μη εγγεγραμμένους χρήστες εάν επιλέξεις να τα διαγράψεις."
- "Απενεργοποίηση λογαριασμού"
+ "Διαγραφή λογαριασμού"
diff --git a/features/deactivation/impl/src/main/res/values-es/translations.xml b/features/deactivation/impl/src/main/res/values-es/translations.xml
index cd0757ba3e..17ae73d6c8 100644
--- a/features/deactivation/impl/src/main/res/values-es/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-es/translations.xml
@@ -10,5 +10,4 @@
"Te eliminará de todas las salas de chat.""Eliminará la información de tu cuenta de nuestro servidor de identidad.""Tus mensajes seguirán siendo visibles para los usuarios registrados, pero no estarán disponibles para los usuarios nuevos o no registrados si decides eliminarlos."
- "Desactivar cuenta"
diff --git a/features/deactivation/impl/src/main/res/values-et/translations.xml b/features/deactivation/impl/src/main/res/values-et/translations.xml
index 95695fef16..bfb41d7665 100644
--- a/features/deactivation/impl/src/main/res/values-et/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-et/translations.xml
@@ -1,14 +1,14 @@
- "Palun kinnita uuesti, et soovid eemaldada oma konto kasutusest"
+ "Palun kinnita uuesti, et soovid kustutada oma kasutajakonto. Seda tegevust ei saa tagasi pöörata.""Kustuta kõik minu sõnumid""Hoiatus: tulevased kasutajad võivad näha poolikuid vestlusi."
- "Sinu konto kasutusest eemaldamine on %1$s ja sellega:"
+ "Sinu konto kustutamine on %1$s ja sellega:""pöördumatu""Sinu kasutajakonto %1$s (sa ei saa enam sellega võrku logida ning kasutajatunnust ei saa enam pruukida).""jäädavalt eemaldatakse kasutusest""Sind logitakse välja kõikidest jututubadest.""Kustutatakse sinu andmed meie isikutuvastusserverist.""Sinu sõnumid on jätkuvalt nähtavad registreeritud kasutajatele, kuid kui otsustad sõnumid kustutada, siis nad nad pole nähtavad uutele ja registreerimata kasutajatele."
- "Eemalda konto kasutusest"
+ "Kustuta kasutajakonto"
diff --git a/features/deactivation/impl/src/main/res/values-fi/translations.xml b/features/deactivation/impl/src/main/res/values-fi/translations.xml
index df2543be70..148fe3d610 100644
--- a/features/deactivation/impl/src/main/res/values-fi/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-fi/translations.xml
@@ -1,14 +1,14 @@
- "Vahvista, että haluat deaktivoida tilisi. Tätä ei voi perua."
+ "Vahvista, että haluat poistaa tilisi. Tätä ei voi perua.""Poista kaikki viestini""Varoitus: Tulevaisuudessa muut voivat nähdä puutteellisia keskusteluja."
- "Tilisi deaktivointia %1$s. Jos teet sen:"
+ "Tilisi poistamista %1$s. Jos teet sen:""ei voi peruuttaa""Tilisi %1$s (et voi kirjautua takaisin sisään, eikä tunnustasi voi käyttää uudelleen).""poistetaan käytöstä pysyvästi""Sinut poistetaan kaikista keskusteluhuoneista.""Tilitietosi poistetaan identiteettipalvelimeltamme.""Viestisi näkyvät edelleen rekisteröityneille käyttäjille, mutta ne eivät ole uusien tai rekisteröimättömien käyttäjien saatavilla, jos päätät poistaa ne."
- "Deaktivoi tili"
+ "Poista tili"
diff --git a/features/deactivation/impl/src/main/res/values-fr/translations.xml b/features/deactivation/impl/src/main/res/values-fr/translations.xml
index 675ac1e1e0..cf69cb3275 100644
--- a/features/deactivation/impl/src/main/res/values-fr/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-fr/translations.xml
@@ -1,14 +1,14 @@
- "Veuillez confirmer que vous souhaitez désactiver votre compte. Cette action ne peut pas être annulée."
+ "Veuillez confirmer que vous souhaitez supprimer votre compte. Cette action ne peut pas être annulée.""Supprimer tous mes messages""Attention : les futurs utilisateurs pourraient voir des conversations incomplètes."
- "La désactivation de votre compte est %1$s, cela va :"
+ "La suppression de votre compte est %1$s, cela va :""irréversible""%1$s votre compte (vous ne pourrez plus vous reconnecter et votre identifiant ne pourra pas être réutilisé).""Désactiver définitivement""Vous retirer de tous les salons et toutes les discussions.""Supprimer les informations de votre compte du serveur d’identité.""Rendre vos messages invisibles aux futurs membres des salons si vous choisissez de les supprimer. Vos messages seront toujours visibles pour les utilisateurs qui les ont déjà récupérés."
- "Désactiver le compte"
+ "Supprimer le compte"
diff --git a/features/deactivation/impl/src/main/res/values-hr/translations.xml b/features/deactivation/impl/src/main/res/values-hr/translations.xml
index 04148fdc48..1d8a02a08c 100644
--- a/features/deactivation/impl/src/main/res/values-hr/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-hr/translations.xml
@@ -10,5 +10,5 @@
"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"
+ "Izbriši račun"
diff --git a/features/deactivation/impl/src/main/res/values-hu/translations.xml b/features/deactivation/impl/src/main/res/values-hu/translations.xml
index 3d3722b8ef..2c3f51ed7a 100644
--- a/features/deactivation/impl/src/main/res/values-hu/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-hu/translations.xml
@@ -1,14 +1,14 @@
- "Erősítse meg, hogy deaktiválja a fiókját. Ez a művelet nem vonható vissza."
+ "Erősítse meg a fiókja törlését. Ez a művelet nem vonható vissza.""Összes saját üzenet törlése""Figyelmeztetés: A jövőbeli felhasználók hiányos beszélgetéseket láthatnak."
- "A fiók deaktiválása %1$s, a következőket okozza:"
+ "Fiókjának törlése: %1$s, ez a következőket eredményezi:""visszafordíthatatlan""%1$s a fiókját (nem fog tudni újra bejelentkezni, és az azonosítója nem használható újra).""Véglegesen letiltja""Eltávolításra kerül az összes csevegőszobából.""Törlésre kerülnek a fiókadatai az azonosítási kiszolgálónkról.""Üzenetei továbbra is láthatóak maradnak a regisztrált felhasználók számára, de nem lesznek elérhetőek az új vagy nem regisztrált felhasználók számára, ha úgy dönt, hogy törli őket."
- "Fiók deaktiválása"
+ "Fiók törlése"
diff --git a/features/deactivation/impl/src/main/res/values-it/translations.xml b/features/deactivation/impl/src/main/res/values-it/translations.xml
index 3fbc9d536b..e3de1ec8bb 100644
--- a/features/deactivation/impl/src/main/res/values-it/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-it/translations.xml
@@ -1,14 +1,14 @@
- "Conferma di voler disattivare il tuo account. Questa azione è irreversibile."
+ "Conferma di voler eliminare il tuo account. Questa azione è irreversibile.""Elimina tutti i miei messaggi""Attenzione: gli utenti futuri potrebbero vedere conversazioni incomplete."
- "La disattivazione del tuo account è %1$s , quindi:"
+ "L\'eliminazione del tuo account è %1$s, e comporterà:""irreversibile""%1$s il tuo account (non puoi riaccedere e il tuo ID non può essere riutilizzato).""Disattiva permanentemente""Ti rimuove da tutte le stanze di chat.""Elimina le informazioni del tuo account dal nostro server di identità.""I tuoi messaggi saranno ancora visibili agli utenti registrati, ma non saranno disponibili per gli utenti nuovi o non registrati se decidi di eliminarli."
- "Disattiva account"
+ "Elimina account"
diff --git a/features/deactivation/impl/src/main/res/values-ja/translations.xml b/features/deactivation/impl/src/main/res/values-ja/translations.xml
index 873b1308ec..53893a594f 100644
--- a/features/deactivation/impl/src/main/res/values-ja/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-ja/translations.xml
@@ -1,14 +1,14 @@
- "アカウントを無効化することを再度確認します。この操作は元に戻せません。"
+ "アカウントを削除しようとしていることを確認しています。この操作は元に戻せません。""メッセージをすべて削除""注意: 新しいユーザーには断片的な会話が表示されます"
- "アカウントを無効化することは %1$s であり、次の変化が生じます:"
+ "アカウントを削除することは %1$s であり、次の変化が生じます:""不可逆""アカウントを %1$s (再度ログイン不可, 同一のIDを再利用不可)""恒久的に無効化する""すべてのチャットルームから退出します。""アカウント提供元サーバーからアカウント情報を削除します。""あなたの会話は、既存ユーザーには引き続き表示されますが、新規ユーザーには表示されなくなります。"
- "アカウントを無効化"
+ "アカウントを削除"
diff --git a/features/deactivation/impl/src/main/res/values-ko/translations.xml b/features/deactivation/impl/src/main/res/values-ko/translations.xml
index 6b7953a4a5..42dd0aa0e2 100644
--- a/features/deactivation/impl/src/main/res/values-ko/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-ko/translations.xml
@@ -10,5 +10,4 @@
"모든 채팅방에서 자신을 제거하세요.""당사의 신원 서버에서 귀하의 계정 정보를 삭제하세요.""메시지는 등록된 사용자에게는 계속 표시되지만, 삭제하면 신규 또는 미등록 사용자는 볼 수 없게 됩니다."
- "계정 비활성화"
diff --git a/features/deactivation/impl/src/main/res/values-pl/translations.xml b/features/deactivation/impl/src/main/res/values-pl/translations.xml
index bddb6a9037..5778123aa5 100644
--- a/features/deactivation/impl/src/main/res/values-pl/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-pl/translations.xml
@@ -1,14 +1,14 @@
- "Potwierdź dezaktywacje konta. Tej akcji nie można cofnąć."
+ "Potwierdź usunięcie konta. Tej akcji nie można cofnąć.""Usuń wszystkie moje wiadomości""Ostrzeżenie: Przyszli użytkownicy mogą zobaczyć niekompletne rozmowy."
- "Dezaktywacja konta jest %1$s, zostanie:"
+ "Usunięcie konta jest %1$s, co spowoduje:""nieodwracalna""%1$s twoje konto (nie będziesz mógł się zalogować, a twoje ID przepadnie).""Permanentnie wyłączy""Usunie Ciebie ze wszystkich pokoi rozmów.""Usunięte wszystkie dane konta z naszego serwera tożsamości.""Twoje wiadomości wciąż będą widoczne dla zarejestrowanych użytkowników, ale nie będą dostępne dla nowych lub niezarejestrowanych użytkowników, jeśli je usuniesz."
- "Dezaktywuj konto"
+ "Usuń konto"
diff --git a/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml b/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml
index 7000a65d47..a986b18a7c 100644
--- a/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-pt-rBR/translations.xml
@@ -10,5 +10,4 @@
"Te remover de todas as salas de conversa.""Apague as informações da sua conta do nosso servidor de identidade.""Suas mensagens ainda estarão visíveis para os usuários registrados, mas não estarão disponíveis para usuários novos ou não registrados se você optar por apagá-las."
- "Desativar conta"
diff --git a/features/deactivation/impl/src/main/res/values-ro/translations.xml b/features/deactivation/impl/src/main/res/values-ro/translations.xml
index acd4c0747d..6176b4584e 100644
--- a/features/deactivation/impl/src/main/res/values-ro/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-ro/translations.xml
@@ -1,14 +1,14 @@
- "Vă rugăm să confirmați că doriți să vă dezactivați contul. Această acțiune nu poate fi anulată."
+ "Vă rugăm să confirmați că doriți să vă ștergeți contul. Această acțiune nu poate fi anulată.""Ștergeți toate mesajele mele""Avertisment: este posibil ca viitorii utilizatori să vadă conversații incomplete."
- "Dezactivarea contului dumneavoastră este %1$s, acesta va:"
+ "Ștergerea contului dumneavoastră este %1$s, acesta va:""ireversibilă""%1$s contul dumneavoastră (nu vă puteți conecta din nou, iar ID-ul dvs. nu poate fi reutilizat).""Dezactivați permanent""Îndepărta din toate camerele de chat.""Șterge informațiile contului dumneavoastră de pe serverul nostru de identitate.""Mesajele dumneavoastră vor fi în continuare vizibile pentru utilizatorii înregistrați, dar nu vor fi disponibile pentru utilizatorii noi sau neînregistrați dacă alegeți să le ștergeți."
- "Dezactivați contul"
+ "Ștergeți contul"
diff --git a/features/deactivation/impl/src/main/res/values-ru/translations.xml b/features/deactivation/impl/src/main/res/values-ru/translations.xml
index 6f595cde29..79c25af475 100644
--- a/features/deactivation/impl/src/main/res/values-ru/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-ru/translations.xml
@@ -1,14 +1,14 @@
- "Вы уверены, что хотите отключить свою учётную запись? Данное действие необратимо."
+ "Вы уверены, что хотите удалить свою учётную запись? Данное действие необратимо.""Удалить все мои сообщения""Внимание: в будущем пользователи могут видеть неполные переписки."
- "Деактивация вашего аккаунта %1$s и означает следующее:"
+ "Удаление вашего аккаунта %1$s, это означает следующее:""необратимо""Ваша учётная запись будет %1$s (вы не сможете войти в неё снова, и другие пользователи не смогут использовать ваше имя пользователя).""Отключить навсегда""Вы будете удалены из всех чатов.""Данные Вашего аккаунта будут удалены с нашего сервера идентификации.""Ваши сообщения по-прежнему будут видны зарегистрированным пользователям, но не будут доступны новым или незарегистрированным пользователям, если вы решите удалить их."
- "Отключить учётную запись"
+ "Удалить аккаунт"
diff --git a/features/deactivation/impl/src/main/res/values-uk/translations.xml b/features/deactivation/impl/src/main/res/values-uk/translations.xml
index 04b32df8b2..62cf66cec1 100644
--- a/features/deactivation/impl/src/main/res/values-uk/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-uk/translations.xml
@@ -10,5 +10,5 @@
"Видалити вас з усіх чатів.""Видаліть інформацію свого облікового запису з нашого сервера ідентифікації.""Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."
- "Деактивувати обліковий запис"
+ "Відключити обліковий запис"
diff --git a/features/deactivation/impl/src/main/res/values-ur/translations.xml b/features/deactivation/impl/src/main/res/values-ur/translations.xml
index 297b29c519..3cad49aeb3 100644
--- a/features/deactivation/impl/src/main/res/values-ur/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-ur/translations.xml
@@ -10,5 +10,4 @@
"آپ کو تمام چیت رومز سے ہٹا دے گا۔""ہمارے شناختی سرور سے اپنے اکاؤنٹ کی معلومات کو حذف کریں۔""آپ کے پیغامات اب بھی رجسٹرڈ صارفین کو نظر آئیں گے لیکن اگر آپ انہیں حذف کرنے کا انتخاب کرتے ہیں تو نئے یا غیر رجسٹرڈ صارفین کے لیے دستیاب نہیں ہوں گے۔"
- "اکاؤنٹ کو غیر فعال کریں"
diff --git a/features/deactivation/impl/src/main/res/values-uz/translations.xml b/features/deactivation/impl/src/main/res/values-uz/translations.xml
index 19a70bb149..e0dcfe59ef 100644
--- a/features/deactivation/impl/src/main/res/values-uz/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-uz/translations.xml
@@ -10,5 +10,5 @@
"Sizni barcha chat xonalaridan olib tashlash.""Hisobingiz haqidagi axborotni identifikatsiya serverimizdan o‘chirib tashlang.""Xabarlaringiz ro‘yxatdan o‘tgan foydalanuvchilarga ko‘rinadi, lekin ularni o‘chirishni tanlasangiz, yangi yoki ro‘yxatdan o‘tmagan foydalanuvchilarga ko‘rinmaydi."
- "Hisobni faolsizlantirish"
+ "Akkauntni o‘chirish"
diff --git a/features/deactivation/impl/src/main/res/values-vi/translations.xml b/features/deactivation/impl/src/main/res/values-vi/translations.xml
index 22bc0a6d6e..f3b48163ed 100644
--- a/features/deactivation/impl/src/main/res/values-vi/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-vi/translations.xml
@@ -1,7 +1,13 @@
+ "Vui lòng xác nhận rằng bạn muốn vô hiệu hóa tài khoản của mình. Hành động này không thể hoàn tác.""Xóa tất cả tin nhắn của tôi""Cảnh báo: Người dùng sau này có thể thấy các cuộc trò chuyện chưa hoàn chỉnh."
+ "Việc vô hiệu hóa tài khoản của bạn là %1$s , nó sẽ:"
+ "không thể đảo ngược"
+ "%1$s Tài khoản của bạn (bạn không thể đăng nhập lại và ID của bạn không thể được sử dụng lại)."
+ "Vô hiệu hóa vĩnh viễn"
+ "Loại bỏ bạn khỏi tất cả các phòng chat."
+ "Xóa thông tin tài khoản của bạn khỏi máy chủ nhận dạng của chúng tôi.""Tin nhắn của bạn vẫn sẽ hiển thị cho người dùng đã đăng ký nhưng sẽ không hiển thị cho người dùng mới hoặc chưa đăng ký nếu bạn chọn xóa chúng."
- "Vô hiệu hóa tài khoản"
diff --git a/features/deactivation/impl/src/main/res/values-zh/translations.xml b/features/deactivation/impl/src/main/res/values-zh/translations.xml
index ca24375d66..3916921652 100644
--- a/features/deactivation/impl/src/main/res/values-zh/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-zh/translations.xml
@@ -1,14 +1,14 @@
- "请确认您要停用您的账户。此操作无法撤消。"
+ "请确认要删除的账户。此操作无法撤消。""删除我的所有消息""警告:未来的用户可能会看到不完整的对话。"
- "停用您的帐户是%1$s,它将:"
- "不可逆转的"
- "%1$s您的账户(您无法登录回来,并且您的ID无法重复使用)。"
+ "正在删除的账户为 %1$s,它将:"
+ "不可逆"
+ "你的账户 %1$s(将无法再登录,并且 ID 无法重复使用)。""永久禁用"
- "将您从所有聊天房间中移除。"
- "从我们的身份服务器中删除您的账户信息。"
- "注册用户仍可看到您的消息,但如果您选择删除它们,新用户或未注册用户将无法看到您的消息。"
- "停用账户"
+ "将你从所有聊天房间中移除。"
+ "从我们的身份服务器中删除你的账户信息。"
+ "注册用户仍可看到你的消息,但如果选择删除它们,新用户或未注册用户将无法看到你的消息。"
+ "删除账户"
diff --git a/features/deactivation/impl/src/main/res/values/localazy.xml b/features/deactivation/impl/src/main/res/values/localazy.xml
index 0380cf1c94..fc12c7d2f8 100644
--- a/features/deactivation/impl/src/main/res/values/localazy.xml
+++ b/features/deactivation/impl/src/main/res/values/localazy.xml
@@ -1,14 +1,14 @@
- "Please confirm that you want to deactivate your account. This action cannot be undone."
+ "Please confirm that you want to delete your account. This action cannot be undone.""Delete all my messages""Warning: Future users may see incomplete conversations."
- "Deactivating your account is %1$s, it will:"
+ "Deleting your account is %1$s, it will:""irreversible""%1$s your account (you can\'t log back in, and your ID can\'t be reused).""Permanently disable""Remove you from all chat rooms.""Delete your account information from our identity server.""Your messages will still be visible to registered users but won’t be available to new or unregistered users if you choose to delete them."
- "Deactivate account"
+ "Delete account"
diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt
index eff479d21c..c672fd666b 100644
--- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt
+++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt
@@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction
@@ -26,33 +29,29 @@ 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.pressTag
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class AccountDeactivationViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back invokes the expected callback`() {
+ fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(eventSink = eventsRecorder),
onBackClick = it,
)
- rule.pressBack()
+ pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
- fun `clicking on Deactivate emits the expected Event`() {
+ fun `clicking on Deactivate emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@@ -60,14 +59,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_deactivate)
+ clickOn(CommonStrings.action_delete)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
}
@Test
- fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() {
+ fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@@ -76,14 +75,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
- rule.pressTag(TestTags.dialogPositive.value)
+ pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
}
@Test
- fun `clicking on retry on the confirmation dialog emits the expected Event`() {
+ fun `clicking on retry on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@@ -92,26 +91,26 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_retry)
+ clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true))
}
@Test
- fun `switching on the erase all switch emits the expected Event`() {
+ fun `switching on the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_deactivate_account_delete_all_messages)
+ clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true))
}
@Test
- fun `switching off the erase all switch emits the expected Event`() {
+ fun `switching off the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
eraseData = true,
@@ -119,15 +118,15 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_deactivate_account_delete_all_messages)
+ clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false))
}
@Config(qualifiers = "h1024dp")
@Test
- fun `typing text in the password field emits the expected Event`() {
+ fun `typing text in the password field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setAccountDeactivationView(
+ setAccountDeactivationView(
state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState(
password = A_PASSWORD,
@@ -135,12 +134,12 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
- rule.onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
+ onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD"))
}
}
-private fun AndroidComposeTestRule.setAccountDeactivationView(
+private fun AndroidComposeUiTest.setAccountDeactivationView(
state: AccountDeactivationState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
index 65fe3fe087..92d8b9b646 100644
--- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
interface EnterpriseService {
val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
+ suspend fun tweakMasUrl(url: String, homeserver: String): String
fun defaultHomeserverList(): List
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt
index 6bd6c78de5..f87dc743e8 100644
--- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt
+++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt
@@ -10,6 +10,7 @@ package io.element.android.features.enterprise.api
interface SessionEnterpriseService {
suspend fun isElementCallAvailable(): Boolean
+ suspend fun tweakMasUrl(url: String): String
suspend fun init()
}
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
index 932d082fd9..6e3ed5d3cc 100644
--- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt
@@ -23,7 +23,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
-
+ override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override fun defaultHomeserverList(): List = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt
index 3441063a8a..9aafcd343c 100644
--- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt
+++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt
@@ -15,5 +15,6 @@ import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun init() = Unit
+ override suspend fun tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true
}
diff --git a/features/enterprise/test/build.gradle.kts b/features/enterprise/test/build.gradle.kts
index 542e73717a..c37fc53de3 100644
--- a/features/enterprise/test/build.gradle.kts
+++ b/features/enterprise/test/build.gradle.kts
@@ -15,6 +15,7 @@ android {
dependencies {
api(projects.features.enterprise.api)
+ implementation(projects.libraries.architecture)
implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
index 3c17a4de7c..805c75be6a 100644
--- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
+++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt
@@ -30,6 +30,7 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
+ private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -38,6 +39,10 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
+ override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
+ tweakMasUrlResult(url, homeserver)
+ }
+
override fun defaultHomeserverList(): List {
return defaultHomeserverListResult()
}
diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt
index 3914c60155..0bcad13033 100644
--- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt
+++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt
@@ -14,10 +14,15 @@ import io.element.android.tests.testutils.simulateLongTask
class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
+ private val tweakMasUrlResult: (String) -> String = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
+ override suspend fun tweakMasUrl(url: String): String = simulateLongTask {
+ tweakMasUrlResult(url)
+ }
+
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}
diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt
index f1e9bd8fc6..57a9f65099 100644
--- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt
+++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.forward.impl
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
@@ -21,34 +24,30 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressTag
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ForwardMessagesViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `cancel error emits the expected event`() {
+ fun `cancel error emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setForwardMessagesView(
+ setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventsRecorder
),
)
- rule.pressTag(TestTags.dialogPositive.value)
+ pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError)
}
@Test
- fun `success invokes onForwardSuccess`() {
+ fun `success invokes onForwardSuccess`() = runAndroidComposeUiTest {
val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnceWithParam?>(data) { callback ->
- rule.setForwardMessagesView(
+ setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Success(data),
eventSink = eventsRecorder
@@ -59,7 +58,7 @@ class ForwardMessagesViewTest {
}
}
-private fun AndroidComposeTestRule.setForwardMessagesView(
+private fun AndroidComposeUiTest.setForwardMessagesView(
state: ForwardMessagesState,
onForwardSuccess: (List) -> Unit = EnsureNeverCalledWithParam(),
) {
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 0ca25c9455..1bfa10daf2 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
@@ -19,6 +19,9 @@ 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.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -90,7 +93,11 @@ fun ChooseSelfVerificationModeView(
Text(
modifier = Modifier
.clickable(onClick = onLearnMore)
- .padding(vertical = 4.dp, horizontal = 16.dp),
+ .padding(vertical = 4.dp, horizontal = 16.dp)
+ .semantics {
+ // Note: there is no Role.Link, so we use Role.Button for better accessibility support
+ role = Role.Button
+ },
text = stringResource(CommonStrings.action_learn_more),
style = ElementTheme.typography.fontBodyLgMedium
)
diff --git a/features/ftue/impl/src/main/res/values-ca/translations.xml b/features/ftue/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..1b645dd5eb
--- /dev/null
+++ b/features/ftue/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,15 @@
+
+
+ "No pots confirmar-la?"
+ "Crea nova clau de recuperació"
+ "Verifica aquest dispositiu per configurar missatges segurs."
+ "Confirma la teva identitat"
+ "Utilitza un altre dispositiu"
+ "Utilitza clau de recuperació"
+ "Ara pots llegir o enviar missatges de manera segura, i qualsevol persona amb qui xategis també confiarà en aquest dispositiu."
+ "Dispositiu verificat"
+ "Utilitza un altre dispositiu"
+ "Esperant un altre dispositiu…"
+ "Pots canviar la configuració més tard."
+ "Permet les notificacions i no perdis cap missatge"
+
diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml
index 474d085df0..0e83b29e35 100644
--- a/features/ftue/impl/src/main/res/values-de/translations.xml
+++ b/features/ftue/impl/src/main/res/values-de/translations.xml
@@ -2,8 +2,8 @@
"Bestätigung unmöglich?""Erstelle einen neuen Wiederherstellungsschlüssel"
- "Verifiziere dieses Gerät, um sichere Chats einzurichten."
- "Bestätige deine Identität"
+ "Wähle eine Verifizierungsmethode, um den sicheren Nachrichtenversand einzurichten."
+ "Bestätige deine digitale Identität""Ein anderes Gerät verwenden""Wiederherstellungsschlüssel verwenden""Du kannst jetzt verschlüsselte Nachrichten lesen und versenden. Dein Chatpartner vertraut nun diesem Gerät."
diff --git a/features/ftue/impl/src/main/res/values-et/translations.xml b/features/ftue/impl/src/main/res/values-et/translations.xml
index 4790fdb716..c013556568 100644
--- a/features/ftue/impl/src/main/res/values-et/translations.xml
+++ b/features/ftue/impl/src/main/res/values-et/translations.xml
@@ -2,8 +2,8 @@
"Kas kinnitamine pole võimalik?""Loo uus taastevõti"
- "Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade."
- "Kinnita, et see oled sina"
+ "Turvalise sõnumside seadistamiseks vali verifitseerimise viis."
+ "Kinnita oma digitaalne identiteet""Kasuta teist seadet""Kasuta taastevõtit""Nüüd saad saata või lugeda sõnumeid turvaliselt ning kõik sinu vestluspartnerid võivad usaldada seda seadet."
diff --git a/features/ftue/impl/src/main/res/values-hr/translations.xml b/features/ftue/impl/src/main/res/values-hr/translations.xml
index d535c660a2..f5aeb642b1 100644
--- a/features/ftue/impl/src/main/res/values-hr/translations.xml
+++ b/features/ftue/impl/src/main/res/values-hr/translations.xml
@@ -2,8 +2,8 @@
"Ne možete potvrditi?""Izradi novi ključ za oporavak"
- "Potvrdite ovaj uređaj kako biste postavili sigurnu razmjenu poruka."
- "Potvrdite svoj identitet"
+ "Odaberite način potvrde za postavljanje sigurne razmjene poruka."
+ "Potvrdite svoj digitalni 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."
diff --git a/features/ftue/impl/src/main/res/values-ja/translations.xml b/features/ftue/impl/src/main/res/values-ja/translations.xml
index 68b69079ef..9a87a3dcfa 100644
--- a/features/ftue/impl/src/main/res/values-ja/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ja/translations.xml
@@ -11,5 +11,5 @@
"他の端末を使用""一方の端末を待機中…""設定は後で変更することができます。"
- "メッセージを見逃さないため通知を許可"
+ "メッセージを見逃さないために通知を許可しましょう"
diff --git a/features/ftue/impl/src/main/res/values-pl/translations.xml b/features/ftue/impl/src/main/res/values-pl/translations.xml
index 5d77c57994..45ca82ede6 100644
--- a/features/ftue/impl/src/main/res/values-pl/translations.xml
+++ b/features/ftue/impl/src/main/res/values-pl/translations.xml
@@ -2,8 +2,8 @@
"Nie możesz potwierdzić?""Utwórz nowy klucz przywracania"
- "Zweryfikuj to urządzenie, aby skonfigurować bezpieczne przesyłanie wiadomości."
- "Potwierdź, że to Ty"
+ "Wybierz sposób weryfikacji, aby skonfigurować bezpieczne wiadomości."
+ "Potwierdź swoją tożsamość cyfrową""Użyj innego urządzenia""Użyj klucza przywracania""Teraz możesz bezpiecznie czytać i wysyłać wiadomości, każdy z kim czatujesz również może ufać temu urządzeniu."
diff --git a/features/ftue/impl/src/main/res/values-pt/translations.xml b/features/ftue/impl/src/main/res/values-pt/translations.xml
index 5b6729f04e..34f39c7bdb 100644
--- a/features/ftue/impl/src/main/res/values-pt/translations.xml
+++ b/features/ftue/impl/src/main/res/values-pt/translations.xml
@@ -3,7 +3,7 @@
"Não é possível confirmar?""Criar uma nova chave de recuperação""Verifica este dispositivo para configurar o envio seguro de mensagens."
- "Confirma que és tu"
+ "Confirma a tua identidade digital""Utilizar outro dispositivo""Utilizar chave de recuperação""Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo."
diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml
index abf72140e8..85b151faa8 100644
--- a/features/ftue/impl/src/main/res/values-ro/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ro/translations.xml
@@ -2,8 +2,8 @@
"Nu puteți confirma?""Creați o nouă cheie de recuperare"
- "Verificați acest dispozitiv pentru a configura mesagerie securizată."
- "Confirmați că sunteți dumneavoastră"
+ "Alegeți cum doriți să vă verificați pentru a configura mesageria securizată."
+ "Confirmați-vă identitatea digitală""Utilizați un alt dispozitiv""Utilizați cheia de recuperare""Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv."
diff --git a/features/ftue/impl/src/main/res/values-uz/translations.xml b/features/ftue/impl/src/main/res/values-uz/translations.xml
index 8edff2c305..2279bb6c92 100644
--- a/features/ftue/impl/src/main/res/values-uz/translations.xml
+++ b/features/ftue/impl/src/main/res/values-uz/translations.xml
@@ -2,8 +2,8 @@
"Tasdiqlay olmayapsizmi?""Yangi tiklash kalitini yarating"
- "Xavfsiz xabarlashuvni sozlash uchun ushbu qurilmani tasdiqlang."
- "Shaxsingizni tasdiqlang"
+ "Xavfsiz xabar almashinuvni sozlash uchun tasdiqlash usulini tanlang."
+ "Raqamli shaxsingizni tasdiqlang""Boshqa qurilmadan foydalanish""Qayta tiklash kalitidan foydalaning""Endi xabarlarni xavfsiz tarzda o‘qish yoki yuborish imkoniyatiga egasiz, shuningdek, siz bilan muloqot qilayotgan har qanday kishi ham bu qurilmaga ishonch bildirishi mumkin."
diff --git a/features/ftue/impl/src/main/res/values-vi/translations.xml b/features/ftue/impl/src/main/res/values-vi/translations.xml
index c70d9be0fb..6ea7d8bf91 100644
--- a/features/ftue/impl/src/main/res/values-vi/translations.xml
+++ b/features/ftue/impl/src/main/res/values-vi/translations.xml
@@ -1,9 +1,14 @@
+ "Không thể xác nhận?"
+ "Tạo khóa khôi phục mới""Chọn phương thức xác minh để bật nhắn tin bảo mật.""Xác nhận danh tính kỹ thuật số của bạn"
+ "Dùng thiết bị khác"
+ "Sử dụng khóa khôi phục""Giờ đây bạn có thể đọc và gửi tin nhắn một cách an toàn, và những người bạn trò chuyện cũng có thể tin tưởng thiết bị này.""Thiết bị được xác thực"
+ "Dùng thiết bị khác""Đang chờ trên thiết bị khác…""Bạn có thể thay đổi cài đặt sau.""Cho phép thông báo để không bỏ lỡ bất kỳ tin nhắn nào"
diff --git a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml
index 6340efdbc3..9bad2d1705 100644
--- a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml
@@ -2,8 +2,8 @@
"無法確認?""建立新的復原金鑰"
- "驗證這部裝置以設定安全通訊。"
- "確認這是你本人"
+ "選擇驗證方式以設定安全訊息傳遞。"
+ "確認您的數位身份""使用另一部裝置""使用復原金鑰""您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。"
diff --git a/features/ftue/impl/src/main/res/values-zh/translations.xml b/features/ftue/impl/src/main/res/values-zh/translations.xml
index 68a48831e0..f9171c9879 100644
--- a/features/ftue/impl/src/main/res/values-zh/translations.xml
+++ b/features/ftue/impl/src/main/res/values-zh/translations.xml
@@ -3,13 +3,13 @@
"无法确认?""创建新的恢复密钥""选择验证方式以设置安全的消息传输。"
- "确认您的数字身份"
- "使用其他设备"
+ "确认你的数字身份"
+ "使用其它设备""使用恢复密钥"
- "现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。"
+ "现在你可以安全地读取或发送消息,并且与你聊天的任何人也可以信任此设备。""设备已验证"
- "使用其他设备"
- "正在等待其他设备……"
- "您可以稍后更改设置。"
+ "使用其它设备"
+ "正在等待其它设备…"
+ "你可以稍后更改设置。""允许通知,绝不错过任何消息"
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt
index 521bf91b37..6e74f58f66 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
@@ -18,65 +21,61 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class ChooseSessionVerificationModeViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Config(qualifiers = "h1024dp")
@Test
- fun `clicking on learn more invokes the expected callback`() {
+ fun `clicking on learn more invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setChooseSelfVerificationModeView(
+ setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(),
onLearnMoreClick = callback,
)
- rule.clickOn(CommonStrings.action_learn_more)
+ clickOn(CommonStrings.action_learn_more)
}
}
@Config(qualifiers = "h1024dp")
@Test
- fun `clicking on use another device calls the callback`() {
+ fun `clicking on use another device calls the callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setChooseSelfVerificationModeView(
+ setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))),
onUseAnotherDevice = callback,
)
- rule.clickOn(R.string.screen_identity_use_another_device)
+ clickOn(R.string.screen_identity_use_another_device)
}
}
@Config(qualifiers = "h1024dp")
@Test
- fun `clicking on enter recovery key calls the callback`() {
+ fun `clicking on enter recovery key calls the callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setChooseSelfVerificationModeView(
+ setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseRecoveryKey = true))),
onEnterRecoveryKey = callback,
)
- rule.clickOn(R.string.screen_identity_confirmation_use_recovery_key)
+ clickOn(R.string.screen_identity_confirmation_use_recovery_key)
}
}
@Config(qualifiers = "h1024dp")
@Test
- fun `clicking on cannot confirm calls the reset keys callback`() {
+ fun `clicking on cannot confirm calls the reset keys callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setChooseSelfVerificationModeView(
+ setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(),
onResetKey = callback,
)
- rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm)
+ clickOn(R.string.screen_identity_confirmation_cannot_confirm)
}
}
- private fun AndroidComposeTestRule.setChooseSelfVerificationModeView(
+ private fun AndroidComposeUiTest.setChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onUseAnotherDevice: () -> Unit = EnsureNeverCalled(),
diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts
index b36ee6aed2..0635da39a5 100644
--- a/features/home/impl/build.gradle.kts
+++ b/features/home/impl/build.gradle.kts
@@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
+ implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api)
implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api)
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 81e7969080..00f285e3f4 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
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.bumble.appyx.core.lifecycle.subscribe
@@ -171,6 +172,7 @@ class HomeFlowNode(
if (loadingJoinedRoomJob.value.isLoading()) {
DelayedVisibility(duration = 400.milliseconds) {
ProgressDialog(
+ properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true),
onDismissRequest = {
loadingJoinedRoomJob.value.dataOrNull()?.cancel()
loadingJoinedRoomJob.value = AsyncData.Uninitialized
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 ff0fc00496..5f55beaf60 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
@@ -57,6 +57,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
@@ -65,8 +66,8 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
+import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.testtags.TestTags
@@ -237,6 +238,7 @@ private fun SpaceFilterButton(
else -> Unit
}
}
+
val isSelected = spaceFiltersState is SpaceFiltersState.Selected
IconButton(
onClick = ::onClick,
@@ -320,7 +322,15 @@ private fun AccountIcon(
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
- contentDescription = if (isCurrentAccount) stringResource(CommonStrings.common_settings) else null,
+ contentDescription = if (isCurrentAccount) {
+ if (showAvatarIndicator) {
+ stringResource(CommonStrings.a11y_settings_with_required_action)
+ } else {
+ stringResource(CommonStrings.common_settings)
+ }
+ } else {
+ null
+ },
)
if (showAvatarIndicator) {
RedIndicatorAtom(
@@ -337,7 +347,7 @@ private fun AccountIcon(
internal fun HomeTopBarPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
- currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ currentUserAndNeighbors = persistentListOf(aMatrixUser(id = "@id:domain", displayName = USER_NAME_ALICE)),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
@@ -358,7 +368,7 @@ internal fun HomeTopBarPreview() = ElementPreview {
internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
- currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ currentUserAndNeighbors = persistentListOf(aMatrixUser(id = "@id:domain", displayName = USER_NAME_ALICE)),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
@@ -379,7 +389,7 @@ internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview {
internal fun HomeTopBarSpacesPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Spaces,
- currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ currentUserAndNeighbors = persistentListOf(aMatrixUser(id = "@id:domain", displayName = USER_NAME_ALICE)),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
@@ -400,7 +410,7 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview {
internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
- currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")),
+ currentUserAndNeighbors = persistentListOf(aMatrixUser(id = "@id:domain", displayName = USER_NAME_ALICE)),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
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 f541417104..72ecb5046d 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
@@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -222,20 +223,17 @@ private fun NameAndTimestampRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = spacedBy(16.dp)
) {
- Row(
- modifier = Modifier.weight(1f),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- // Name
- Text(
- style = ElementTheme.typography.fontBodyLgMedium,
- 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
- )
- }
+ Text(
+ modifier = Modifier
+ .weight(1f)
+ .clipToBounds(),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ 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
+ )
// Timestamp
Text(
text = timestamp ?: "",
@@ -262,12 +260,12 @@ private fun InviteSubtitle(
}
if (subtitle != null) {
Text(
+ modifier = modifier.clipToBounds(),
text = subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.roomListRoomMessage,
- modifier = modifier,
)
}
}
@@ -326,7 +324,9 @@ private fun MessagePreviewAndIndicatorRow(
val messagePreview = room.latestEvent.content()
val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString())
Text(
- modifier = Modifier.weight(1f),
+ modifier = Modifier
+ .weight(1f)
+ .clipToBounds(),
text = annotatedMessagePreview,
color = ElementTheme.colors.roomListRoomMessage,
style = ElementTheme.typography.fontBodyMdRegular,
@@ -381,7 +381,9 @@ private fun InviteNameAndIndicatorRow(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- modifier = Modifier.weight(1f),
+ modifier = Modifier
+ .weight(1f)
+ .clipToBounds(),
style = ElementTheme.typography.fontBodyLgMedium,
text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic.takeIf { name == null },
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 3ff4339bb2..56b2c1ade4 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
@@ -83,8 +83,8 @@ class RoomListDataSource(
val loadingState = roomList.loadingState
- fun launchIn(coroutineScope: CoroutineScope) {
- roomList
+ fun launchIn(coroutineScope: CoroutineScope): Job {
+ return roomList
.summaries
.onEach { roomSummaries ->
replaceWith(roomSummaries)
@@ -212,6 +212,7 @@ class RoomListDataSource(
private suspend fun rebuildAllRoomSummaries() {
lock.withLock {
roomList.summaries.replayCache.firstOrNull()?.let { roomSummaries ->
+ diffCacheUpdater.updateWith(roomSummaries)
buildAndEmitAllRooms(roomSummaries, useCache = false)
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
index 26054d7e56..e34f2845da 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListRoomSummaryFactory.kt
@@ -19,7 +19,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.matrix.api.room.CallIntentConsensus
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
-import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.ui.model.getAvatarData
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
index eefb2d6484..09e6c2e6c9 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryProvider.kt
@@ -11,6 +11,10 @@ package io.element.android.features.home.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.LAST_MESSAGE
+import io.element.android.libraries.designsystem.preview.ROOM_NAME
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
+import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -85,16 +89,16 @@ open class RoomListRoomSummaryProvider : PreviewParameterProvider Unit,
) {
Column(
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
) {
ListItem(
headlineContent = {
@@ -212,23 +217,16 @@ private fun RoomListModalBottomSheetContent(
}
}
-// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
-// see: https://issuetracker.google.com/issues/283843380
-// Remove this preview when the issue is fixed.
@PreviewsDayNight
@Composable
-internal fun RoomListModalBottomSheetContentPreview(
+internal fun RoomListContextMenuPreview(
@PreviewParameter(RoomListStateContextMenuShownProvider::class) contextMenu: RoomListState.ContextMenu.Shown
) = ElementPreview {
- RoomListModalBottomSheetContent(
+ RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = true,
- onRoomMarkReadClick = {},
- onRoomMarkUnreadClick = {},
onRoomSettingsClick = {},
- onLeaveRoomClick = {},
- onFavoriteChange = {},
- onClearCacheRoomClick = {},
onReportRoomClick = {},
+ eventSink = {},
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
index 523e677a57..0a7a29ebc2 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenu.kt
@@ -13,16 +13,21 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
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.appconfig.ProtectionConfig
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.libraries.core.extensions.toSafeLength
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
@@ -42,9 +47,14 @@ fun RoomListDeclineInviteMenu(
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvent.HideDeclineInviteMenu) },
+ scrollable = false,
) {
RoomListDeclineInviteMenuContent(
- roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value,
+ roomName = menu.roomSummary.name?.toSafeLength(
+ maxLength = ProtectionConfig.MAX_ROOM_NAME_LENGTH,
+ ellipsize = true,
+ )
+ ?: menu.roomSummary.roomId.value,
onDeclineClick = {
eventSink(RoomListEvent.HideDeclineInviteMenu)
eventSink(RoomListEvent.DeclineInvite(menu.roomSummary, false))
@@ -74,7 +84,8 @@ private fun RoomListDeclineInviteMenuContent(
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(all = 16.dp),
+ .padding(all = 16.dp)
+ .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
@@ -112,16 +123,15 @@ private fun RoomListDeclineInviteMenuContent(
}
}
-// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
-// see: https://issuetracker.google.com/issues/283843380
-// Remove this preview when the issue is fixed.
@PreviewsDayNight
@Composable
-internal fun RoomListDeclineInviteMenuContentPreview() = ElementPreview {
- RoomListDeclineInviteMenuContent(
- roomName = "Room name",
- onCancelClick = {},
- onDeclineClick = {},
+internal fun RoomListDeclineInviteMenuPreview(
+ @PreviewParameter(RoomListStateDeclineInviteMenuShownProvider::class) menu: RoomListState.DeclineInviteMenu.Shown,
+) = ElementPreview {
+ RoomListDeclineInviteMenu(
+ menu = menu,
+ canReportRoom = false,
onDeclineAndBlockClick = {},
+ eventSink = {},
)
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownProvider.kt
new file mode 100644
index 0000000000..73d4785e96
--- /dev/null
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateDeclineInviteMenuShownProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.home.impl.roomlist
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
+import io.element.android.features.home.impl.model.RoomListRoomSummary
+import io.element.android.features.home.impl.model.aRoomListRoomSummary
+
+open class RoomListStateDeclineInviteMenuShownProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aDeclineInviteMenuShown(),
+ aDeclineInviteMenuShown(
+ aRoomListRoomSummary(
+ name = LoremIpsum(500).values.first(),
+ )
+ ),
+ aDeclineInviteMenuShown(
+ aRoomListRoomSummary(
+ name = null,
+ )
+ ),
+ )
+}
+
+internal fun aDeclineInviteMenuShown(
+ roomSummary: RoomListRoomSummary = aRoomListRoomSummary(),
+) = RoomListState.DeclineInviteMenu.Shown(
+ roomSummary = roomSummary,
+)
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
index fb77c74203..1f0d6ff7d9 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersView.kt
@@ -81,7 +81,8 @@ fun SpaceFiltersView(
if (state is SpaceFiltersState.Selecting) {
state.eventSink(SpaceFiltersEvent.Selecting.Cancel)
}
- }
+ },
+ scrollable = false,
) {
Box(
modifier = Modifier
diff --git a/features/home/impl/src/main/res/values-ca/translations.xml b/features/home/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..e37db27f23
--- /dev/null
+++ b/features/home/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,49 @@
+
+
+ "Desactiva l\'optimització de bateria d\'aquesta aplicació per assegurar-te de rebre totes les notificacions."
+ "No arriben les notificacions?"
+ "Recupera la teva identitat criptogràfica i l\'historial de missatges amb una clau de recuperació si has perdut l\'accés a tots els teus dispositius existents."
+ "Configura la recuperació"
+ "Configura la recuperació per protegir el teu compte"
+ "Confirma la clau de recuperació per mantenir l\'accés a l\'emmagatzematge de claus i a l\'historial de missatges."
+ "Introdueix clau de recuperació"
+ "Has oblidat la clau de recuperació?"
+ "L\'emmagatzematge de claus no està sincronitzat"
+ "Per assegurar que mai et perdis una trucada important, canvia la configuració per permetre les notificacions en pantalla completa quan el telèfon està bloquejat."
+ "Millora l\'experiència de les trucades"
+ "Xats"
+ "Segur que vols rebutjar la invitació per unir-te a %1$s?"
+ "Rebutja invitació"
+ "Segur que vols rebutjar el xat privat amb %1$s?"
+ "Rebutja xat"
+ "Sense invitacions"
+ "%1$s (%2$s) t\'ha convidat"
+ "Aquest procés només s\'ha de fer una vegada, gràcies per esperar."
+ "Configurant compte."
+ "Crea un nou xat o sala"
+ "Comença enviant un missatge a algú."
+ "Encara no hi ha xats."
+ "Preferits"
+ "Pots afegir un xat a preferits a la configuració del xat.
+De moment, pots desseleccionar els filtres per veure tots els xats."
+ "Encara no tens cap xat preferit"
+ "Invitacions"
+ "No tens cap invitació pendent."
+ "Prioritat baixa"
+ "Pots desseleccionar els filtres per veure els altres xats"
+ "Cap xats per a aquesta selecció"
+ "Persones"
+ "Encara no tens cap xat directe"
+ "Sales"
+ "Encara no pertanys a cap sala"
+ "No llegits"
+ "Enhorabona!
+No tens missatges sense llegir!"
+ "Sol·licitud d\'unió enviada"
+ "Xats"
+ "Marca com a llegit"
+ "Marca com a no llegit"
+ "La sala ha estat actualitzada"
+ "Sembla que estàs utilitzant un dispositiu nou. Verifica\'l amb un altre dispositiu per accedir als teus missatges xifrats."
+ "Verifica que ets tu"
+
diff --git a/features/home/impl/src/main/res/values-de/translations.xml b/features/home/impl/src/main/res/values-de/translations.xml
index f504d5c8e1..ee6cef29de 100644
--- a/features/home/impl/src/main/res/values-de/translations.xml
+++ b/features/home/impl/src/main/res/values-de/translations.xml
@@ -5,9 +5,9 @@
"Kommen die Benachrichtigungen nicht an?""Dein Benachrichtigungs-Ping wurde aktualisiert – klarer, schneller und weniger störend.""Wir haben deine Sounds aktualisiert"
- "Stelle Deine kryptographische Identität und Deinen Nachrichtenverlauf mit Hilfe eines Wiederherstellungsschlüssels wieder her, falls du alle deine Geräte verloren haben solltest"
- "Wiederherstellung einrichten"
- "Wiederherstellung einrichten"
+ "Deine Chats werden automatisch gesichert und mit einer Ende-zu-Ende-Verschlüsselung geschützt. Um dieses Backup wiederherzustellen und deine digitale Identität zu bewahren, falls du den Zugriff auf alle deine Geräte verlierst, benötigst du deinen Wiederherstellungsschlüssel."
+ "Wiederherstellungsschlüssel einrichten"
+ "Sichere deine Chats""Bestätige deinen Wiederherstellungsschlüssel, um weiterhin auf deinen Schlüsselspeicher und den Nachrichtenverlauf zugreifen zu können.""Gib deinen Wiederherstellungsschlüssel ein""Hast du deinen Wiederherstellungsschlüssel vergessen?"
diff --git a/features/home/impl/src/main/res/values-et/translations.xml b/features/home/impl/src/main/res/values-et/translations.xml
index c1f61bfa29..4ba1695d7e 100644
--- a/features/home/impl/src/main/res/values-et/translations.xml
+++ b/features/home/impl/src/main/res/values-et/translations.xml
@@ -5,9 +5,9 @@
"Sa ei näe kõiki teavitusi?""Sinu nutiseadme teavituste heli on uuenenud - see on nüüd selgem, kiirem ja vähem häiriv.""Oleme sinu helisid värskendanud"
- "Loo uus taastevõti, mida saad kasutada oma krüptitud sõnumite ajaloo taastamisel olukorras, kus kaotad ligipääsu oma seadmetele."
- "Seadista andmete taastamine"
- "Seadista taastamine"
+ "Sinu vestlused on automaatselt varundatud kasutades läbivat krüptimist. Kui peaksid kaotama ligipääsu kõikidele oma seadmetele, siis selle varukoopia taastamiseks ja oma digitaalse identiteedi säilitamiseks, on vaja taastevõtit."
+ "Seadista taastevõti"
+ "Varunda oma vestlused""Säilitamaks ligipääsu vestluste ja krüptovõtmete varukoopiale, palun sisesta kinnituseks oma taastevõti.""Sisesta oma taastevõti""Kas unustasid oma taastevõtme?"
diff --git a/features/home/impl/src/main/res/values-fa/translations.xml b/features/home/impl/src/main/res/values-fa/translations.xml
index aec50309d8..b0c6a30bc4 100644
--- a/features/home/impl/src/main/res/values-fa/translations.xml
+++ b/features/home/impl/src/main/res/values-fa/translations.xml
@@ -4,7 +4,7 @@
"از کار انداختن بهینه سازی""آگاهیها نمیرسند؟""بازگردانی تاریخچهٔ پیامها و هویت رمزنگاشتهتان با کلید بازیابی در صورت از دست دادن همهٔ افزارههای موجودتان."
- "برپایی بازیابی"
+ "دریافت کلید بازیابی""برپایی بازیابی""کلید بازیابی خود را تأیید کنید تا دسترسی به حافظه کلیدها و تاریخچه پیامهایتان حفظ شود .""ورود کلید بازیابیتان"
@@ -25,6 +25,7 @@
"آغاز با پیام دادن به کسی.""هنوز گپی وجود ندارد.""علاقهمندیها"
+ "میتوانید در تنظیمات چت، یک چت را به موارد دلخواه خود اضافه کنید. فعلاً میتوانید فیلترها را غیرفعال کنید تا چتهای دیگر خود را ببینید.""هنوز هیچ گپ مورد علاقهای ندارید""دعوتها""هیچ دعوت منتظری ندارید."
diff --git a/features/home/impl/src/main/res/values-hr/translations.xml b/features/home/impl/src/main/res/values-hr/translations.xml
index 233cac78d8..eae429a0e5 100644
--- a/features/home/impl/src/main/res/values-hr/translations.xml
+++ b/features/home/impl/src/main/res/values-hr/translations.xml
@@ -5,9 +5,9 @@
"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"
+ "Vaši se razgovori automatski sigurnosno kopiraju enkripcijom od početka do kraja. Da biste vratili ovu sigurnosnu kopiju i zadržali svoj digitalni identitet kada izgubite pristup svim svojim uređajima, trebat će vam ključ za oporavak."
+ "ključ za oporavak"
+ "Napravite sigurnosnu kopiju svojih razgovora""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?"
@@ -50,6 +50,7 @@ Nemate nepročitanih poruka!""Označi kao pročitano""Označi kao nepročitano""Ova je soba nadograđena"
+ "Vaši prostori""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/main/res/values-ja/translations.xml b/features/home/impl/src/main/res/values-ja/translations.xml
index 3e4b16b682..c1c1ade1ff 100644
--- a/features/home/impl/src/main/res/values-ja/translations.xml
+++ b/features/home/impl/src/main/res/values-ja/translations.xml
@@ -38,7 +38,7 @@
"低い優先度のチャットはまだありません""フィルターを解除して他のチャットを表示できます""この選択中にチャットがありません"
- "人"
+ "人々""まだダイレクトメッセージは届いていません""ルーム""まだルームに参加していません"
diff --git a/features/home/impl/src/main/res/values-pl/translations.xml b/features/home/impl/src/main/res/values-pl/translations.xml
index 73e0b5e52e..0c1b07f27b 100644
--- a/features/home/impl/src/main/res/values-pl/translations.xml
+++ b/features/home/impl/src/main/res/values-pl/translations.xml
@@ -5,9 +5,9 @@
"Powiadomienia nie dochodzą?""Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy.""Odświeżyliśmy Twoje dźwięki"
- "Wygeneruj nowy klucz przywracania, którego można użyć do przywrócenia historii wiadomości szyfrowanych w przypadku utraty dostępu do swoich urządzeń."
- "Skonfiguruj przywracanie"
- "Skonfiguruj przywracanie"
+ "Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."
+ "Uzyskaj klucz przywracania"
+ "Utwórz kopię zapasową swoich czatów""Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości.""Wprowadź klucz przywracania""Zapomniałeś klucza przywracania?"
@@ -50,6 +50,7 @@ Nie masz żadnych nieprzeczytanych wiadomości!""Oznacz jako przeczytane""Oznacz jako nieprzeczytane""Ten pokój został ulepszony"
+ "Twoje przestrzenie""Wygląda na to, że używasz nowego urządzenia. Zweryfikuj się innym urządzeniem, aby uzyskać dostęp do zaszyfrowanych wiadomości.""Potwierdź, że to Ty"
diff --git a/features/home/impl/src/main/res/values-pt/translations.xml b/features/home/impl/src/main/res/values-pt/translations.xml
index 9870014904..6c6a72930e 100644
--- a/features/home/impl/src/main/res/values-pt/translations.xml
+++ b/features/home/impl/src/main/res/values-pt/translations.xml
@@ -6,7 +6,7 @@
"O toque de notificação foi atualizado — mais claro, mais rápido e menos perturbador.""Atualizámos os seus sons""Recupera a tua identidade criptográfica e o histórico de mensagens com uma chave de recuperação se tiveres perdido todos os teus dispositivos existentes."
- "Configurar recuperação"
+ "Chave de recuperação""Configurar a recuperação""Confirma a tua chave de recuperação para manteres o acesso ao teu armazenamento de chaves e ao histórico de mensagens.""Introduz a tua chave de recuperação"
diff --git a/features/home/impl/src/main/res/values-ro/translations.xml b/features/home/impl/src/main/res/values-ro/translations.xml
index e4a80b4fb7..14cf100c22 100644
--- a/features/home/impl/src/main/res/values-ro/translations.xml
+++ b/features/home/impl/src/main/res/values-ro/translations.xml
@@ -5,9 +5,9 @@
"Nu primiți notificări?""Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar.""Am reîmprospătat sunetele"
- "Recuperați-vă identitatea criptografică și mesajele anterioare cu o cheie de recuperare dacă ați pierdut toate dispozitivele existente."
- "Configurați recuperarea"
- "Configurați recuperarea pentru a vă proteja contul"
+ "Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."
+ "Obțineți cheia de recuperare"
+ "Faceți un backup al mesajelor""Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup.""Introduceți cheia de recuperare""Ați uitat cheia de recuperare?"
@@ -50,6 +50,7 @@ Nu aveți mesaje necitite!""Marcați ca citită""Marcați ca necitită""Această cameră a fost modernizată."
+ "Spațiile dumneavoastră""Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate.""Verificați că sunteți dumneavoastră"
diff --git a/features/home/impl/src/main/res/values-uz/translations.xml b/features/home/impl/src/main/res/values-uz/translations.xml
index e5ef24e80a..6079389609 100644
--- a/features/home/impl/src/main/res/values-uz/translations.xml
+++ b/features/home/impl/src/main/res/values-uz/translations.xml
@@ -5,9 +5,9 @@
"Bildirishnoma kelmayaptimi?""Xabarnoma signali yangilandi — endi u aniqroq, tezroq va kamroq halal beradigan bo‘ldi.""Tovushlaringiz yangilandi"
- "Mavjud barcha qurilmalarni yoʻqotgan boʻlsangiz, kriptografik kimligingizni va xabarlar tarixini qayta tiklovchi kalit bilan saqlab qoʻying."
+ "Chatlaringiz avtomatik ravishda boshidan oxirigacha shifrlash bilan zaxiralanadi. Bu zaxirani tiklash va barcha qurilmalaringizdan foydalana olmay qolganingizda raqamli identifikatoringizni saqlab qolish uchun sizga tiklash kaliti kerak bo‘ladi.""Qayta tiklashni sozlang"
- "Hisobingizni himoya qilish uchun tiklashni sozlang"
+ "Chatlaringizni zaxiralang""Kalit saqlash joyingiz va xabarlar tarixingizga kirishni saqlab qolish uchun tiklash kalitingizni tasdiqlang.""Qayta tiklash kalitingizni kiriting""Tiklash kalitini unutdingizmi?"
@@ -50,6 +50,7 @@ Sizda oʻqilmagan xabarlar yoʻq!""Oʻqilgan deb belgilash""Oʻqilmagan deb belgilash""Bu xona yangilandi"
+ "Maydonlaringiz""Siz yangi qurilmadan foydalanayotganga o‘xshaysiz. Shifrlangan xabarlaringizga kirish uchun boshqa qurilma bilan tasdiqlang.""Siz ekanligingizni tasdiqlang"
diff --git a/features/home/impl/src/main/res/values-vi/translations.xml b/features/home/impl/src/main/res/values-vi/translations.xml
index bc62880fff..49fdaf46c9 100644
--- a/features/home/impl/src/main/res/values-vi/translations.xml
+++ b/features/home/impl/src/main/res/values-vi/translations.xml
@@ -12,6 +12,8 @@
"Nhập khóa khôi phục của bạn.""Bạn quên khóa khôi phục?”""Dữ liệu khóa của bạn không còn đồng bộ"
+ "Để đảm bảo bạn không bỏ lỡ bất kỳ cuộc gọi quan trọng nào, vui lòng thay đổi cài đặt để cho phép thông báo toàn màn hình khi điện thoại của bạn bị khóa."
+ "Nâng cao trải nghiệm cuộc gọi của bạn""Cuộc trò chuyện""Bạn có chắc muốn từ chối lời mời tham gia %1$s không?""Từ chối lời mời"
diff --git a/features/home/impl/src/main/res/values-zh-rTW/translations.xml b/features/home/impl/src/main/res/values-zh-rTW/translations.xml
index 20ee59da98..b52ac9ad8b 100644
--- a/features/home/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/home/impl/src/main/res/values-zh-rTW/translations.xml
@@ -5,9 +5,9 @@
"沒收到通知?""您的通知提示音已更新,更清晰、更快、更不易分心。""我們已更新您的音效設定"
- "若您遺失了所有現有裝置,則請使用復原金鑰以救援您的密碼學身份與訊息歷史紀錄。"
- "設定復原"
- "設定備援以保護您的帳號"
+ "您的聊天會自動使用端到端加密備份。若您失去對您所有裝置的存取權,且要還原此備份並保留您的數位身份的話,您就會需要您的還原金鑰。"
+ "取得還原金鑰"
+ "備份您的聊天""確認您的復原金鑰以維持對金鑰儲存空間與訊息歷史紀錄的存取權。""輸入您的復原金鑰""忘記了您的復原金鑰?"
@@ -50,6 +50,7 @@
"標為已讀""標為未讀""此聊天室已升級"
+ "您的空間""您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。""驗證這是您本人"
diff --git a/features/home/impl/src/main/res/values-zh/translations.xml b/features/home/impl/src/main/res/values-zh/translations.xml
index 58e5b1eb91..39900a6f0b 100644
--- a/features/home/impl/src/main/res/values-zh/translations.xml
+++ b/features/home/impl/src/main/res/values-zh/translations.xml
@@ -1,56 +1,56 @@
- "请关闭本应用的电池优化设置,确保不错过任何消息通知。"
+ "对此 app 禁用电池优化以确保不错过任何通知。""禁用优化""通知未送达?"
- "您的通知提示音已升级 - 更清晰、更快速、干扰更少。"
- "我们已更新您的声音"
- "生成新的恢复密钥,该密钥可用于在您无法访问设备时恢复加密的消息历史记录。"
+ "通知提示音已升级:更清晰、更快速、干扰更少。"
+ "我们已更新你的声音"
+ "你的聊天已被端到端加密自动备份。如果你无法访问所有设备,则需要使用恢复密钥并保留数字身份。""获取恢复密钥"
- "设置恢复"
+ "备份聊天""确认恢复密钥,以保持对密钥存储和消息历史的访问。""输入恢复密钥""忘记了恢复密钥?""你的密钥存储已不同步"
- "为确保您不会错过重要来电,请更改设置以允许锁屏时的全屏通知。"
+ "为确保你不会错过重要来电,请更改设置以允许锁屏时的全屏通知。""提升通话体验"
- "全部聊天"
+ "聊天""空间"
- "您确定要拒绝加入 %1$s 的邀请吗?"
+ "你确定要拒绝加入 %1$s 的邀请?""拒绝邀请"
- "您确定要拒绝与 %1$s 开始私聊吗?"
+ "你确定要拒绝与 %1$s 私聊?""拒绝聊天""没有邀请"
- "%1$s (%2$s)邀请了你"
- "这是一个一次性的过程,感谢您的等待。"
- "设置您的账户。"
- "创建新的对话或聊天室"
+ "%1$s(%2$s)邀请了你"
+ "此为一次性流程,感谢等待。"
+ "设置账户。"
+ "创建新的对话或房间""清除筛选条件""通过向某人发送消息来开始。"
- "还没有聊天。"
- "收藏夹"
- "可以在聊天设置里将聊天添加到收藏夹中。
-现在,可以取消选择过滤器以查看其他对话。"
- "您未收藏任何聊天"
+ "暂无聊天。"
+ "收藏"
+ "可以在聊天设置里将聊天添加到收藏夹。
+现在可以取消选择筛选器以查看其它对话。"
+ "你尚未收藏任何聊天""邀请""没有待处理的邀请。""低优先级"
- "您还没有任何低优先级聊天"
- "您可以取消选择过滤器以查看其他对话"
- "您没有关于此选项的聊天"
- "用户"
- "目前您还没有私信"
- "聊天室"
- "您尚未进入任何聊天室"
+ "你暂无任何低优先级聊天"
+ "你可以取消选择筛选器以查看其它对话"
+ "你暂无适用于此选项的聊天"
+ "人员"
+ "你暂无任何私聊"
+ "房间"
+ "你尚未进入任何房间""未读""恭喜!
没有任何未读消息!"
- "加入请求已发送"
- "全部聊天"
- "标记为已读"
- "标记为未读"
+ "加入申请已发送"
+ "聊天"
+ "设为已读"
+ "设为未读""此房间已升级"
- "您的空间"
- "您似乎正在使用新设备。使用另一台设备进行验证以访问您的加密消息。"
+ "你的空间"
+ "你似乎正在使用新设备。使用另一台设备进行验证以访问加密消息。""验证是你本人"
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 1ce5061356..6df5e05df5 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
@@ -14,6 +14,9 @@ import io.element.android.features.home.impl.FakeDateTimeObserver
import io.element.android.libraries.androidutils.system.DateTimeObserver
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID_2
+import io.element.android.libraries.matrix.test.A_ROOM_ID_3
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.FakeDynamicRoomList
@@ -100,11 +103,169 @@ class RoomListDataSourceTest {
}
}
+ /**
+ * Tracking issue #4182: rooms duplicated in the room list around midnight.
+ *
+ * If the SDK ever leaks a list containing the same roomId twice (the suspected cause of #4182),
+ * the UI mapper's `distinctBy` safety net in [RoomListDataSource.buildAndEmitAllRooms] must
+ * remove the duplicate AND `analyticsService.trackError` must fire so the team can root-cause
+ * it via Sentry.
+ */
+ @Test
+ fun `when SDK summaries source contains duplicate roomIds, UI layer dedupes and reports trackError`() = runTest {
+ val analyticsService = FakeAnalyticsService()
+ val duplicatedSummaries = listOf(
+ aRoomSummary(roomId = A_ROOM_ID),
+ aRoomSummary(roomId = A_ROOM_ID),
+ aRoomSummary(roomId = A_ROOM_ID_2),
+ )
+ val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(duplicatedSummaries))
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ ).apply {
+ postState(RoomListService.State.Running)
+ }
+ val roomListDataSource = createRoomListDataSource(
+ roomListService = roomListService,
+ analyticsService = analyticsService,
+ )
+
+ roomListDataSource.roomSummariesFlow.test {
+ roomListDataSource.launchIn(backgroundScope)
+ val list = awaitItem()
+ assertThat(list.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
+ assertThat(analyticsService.trackedErrors).hasSize(1)
+ }
+ }
+
+ /**
+ * Tracking issue #4182.
+ *
+ * Targeted scenario: a `DateChanged` tick fires after an initial SDK emit, then a follow-up
+ * SDK emit lands (mimicking "midnight, then a new message arrives"). Even though the diffCache
+ * is bypassed during the rebuild (`useCache = false`), the final state must contain each
+ * roomId exactly once and trackError must not fire on a happy path.
+ */
+ @Test
+ fun `interleaved date change and SDK update with overlapping content does not produce duplicates`() = runTest {
+ val analyticsService = FakeAnalyticsService()
+ val summariesFlow = MutableStateFlow(
+ listOf(
+ aRoomSummary(roomId = A_ROOM_ID),
+ aRoomSummary(roomId = A_ROOM_ID_2),
+ )
+ )
+ val roomList = FakeDynamicRoomList(summaries = summariesFlow)
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ ).apply {
+ postState(RoomListService.State.Running)
+ }
+ val dateTimeObserver = FakeDateTimeObserver()
+ val roomListDataSource = createRoomListDataSource(
+ roomListService = roomListService,
+ dateTimeObserver = dateTimeObserver,
+ analyticsService = analyticsService,
+ )
+
+ roomListDataSource.roomSummariesFlow.test {
+ roomListDataSource.launchIn(backgroundScope)
+ val initial = awaitItem()
+ assertThat(initial.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
+
+ // Midnight ticks while the cache holds [A_ROOM_ID, A_ROOM_ID_2]
+ dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
+ val afterMidnight = awaitItem()
+ assertThat(afterMidnight.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
+
+ // A new message bumps A_ROOM_ID — different unread count makes the StateFlow see this
+ // as a new value
+ summariesFlow.value = listOf(
+ aRoomSummary(roomId = A_ROOM_ID, numUnreadMessages = 1),
+ aRoomSummary(roomId = A_ROOM_ID_2),
+ )
+ val afterMessage = awaitItem()
+ assertThat(afterMessage.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
+ assertThat(afterMessage.map { it.roomId }.toSet()).hasSize(afterMessage.size)
+
+ // Second midnight rebuild after the new message
+ dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
+ val afterSecondMidnight = awaitItem()
+ assertThat(afterSecondMidnight.map { it.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_2).inOrder()
+ assertThat(afterSecondMidnight.map { it.roomId }.toSet()).hasSize(afterSecondMidnight.size)
+
+ assertThat(analyticsService.trackedErrors).isEmpty()
+ }
+ }
+
+ @Test
+ fun `regression test for race with DateTimeObserver and new items`() = runTest {
+ val roomList = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2))))
+ val roomListService = FakeRoomListService(
+ createRoomListLambda = { roomList }
+ ).apply {
+ postState(RoomListService.State.Running)
+ }
+ val dateTimeObserver = FakeDateTimeObserver()
+ var dateFormatterResult = "Today"
+ val dateFormatter = FakeDateFormatter({ _, _, _ -> dateFormatterResult })
+ val roomListDataSource = createRoomListDataSource(
+ roomListService = roomListService,
+ roomListRoomSummaryFactory = aRoomListRoomSummaryFactory(
+ dateFormatter = dateFormatter,
+ ),
+ dateTimeObserver = dateTimeObserver,
+ )
+ roomListDataSource.roomSummariesFlow.test {
+ // Observe room list items changes
+ val job = roomListDataSource.launchIn(backgroundScope)
+ // Get the initial room list
+ val initialRoomList = awaitItem()
+ assertThat(initialRoomList).hasSize(2)
+ assertThat(initialRoomList[0].roomId).isEqualTo(A_ROOM_ID)
+ assertThat(initialRoomList[0].timestamp).isEqualTo(dateFormatterResult)
+ assertThat(initialRoomList[1].roomId).isEqualTo(A_ROOM_ID_2)
+ assertThat(initialRoomList[1].timestamp).isEqualTo(dateFormatterResult)
+
+ // Stop processing room list updates so we can force a race condition with the date time observer updates
+ job.cancel()
+
+ // Trigger a date change and a new item at the same time
+ dateFormatterResult = "Yesterday"
+ roomList.summaries.tryEmit(listOf(aRoomSummary(roomId = A_ROOM_ID), aRoomSummary(roomId = A_ROOM_ID_3), aRoomSummary(roomId = A_ROOM_ID_2)))
+ dateTimeObserver.given(DateTimeObserver.Event.DateChanged(Instant.MIN, Instant.now()))
+
+ // The race condition would have caused the cache indices to be corrupted and only 2 items would be emitted
+ val rebuiltRoomList = awaitItem()
+ assertThat(rebuiltRoomList).hasSize(3)
+ assertThat(rebuiltRoomList[0].roomId).isEqualTo(A_ROOM_ID)
+ assertThat(rebuiltRoomList[0].timestamp).isEqualTo(dateFormatterResult)
+ assertThat(rebuiltRoomList[1].roomId).isEqualTo(A_ROOM_ID_3)
+ assertThat(rebuiltRoomList[1].timestamp).isEqualTo(dateFormatterResult)
+ assertThat(rebuiltRoomList[2].roomId).isEqualTo(A_ROOM_ID_2)
+ assertThat(rebuiltRoomList[2].timestamp).isEqualTo(dateFormatterResult)
+
+ // Restart processing room list updates
+ roomListDataSource.launchIn(backgroundScope)
+
+ // Check there is a new list and it's not the same as the previous one
+ val newRoomList = awaitItem()
+ assertThat(newRoomList).hasSize(3)
+ assertThat(newRoomList[0].roomId).isEqualTo(A_ROOM_ID)
+ assertThat(newRoomList[0].timestamp).isEqualTo(dateFormatterResult)
+ assertThat(newRoomList[1].roomId).isEqualTo(A_ROOM_ID_3)
+ assertThat(newRoomList[1].timestamp).isEqualTo(dateFormatterResult)
+ assertThat(newRoomList[2].roomId).isEqualTo(A_ROOM_ID_2)
+ assertThat(newRoomList[2].timestamp).isEqualTo(dateFormatterResult)
+ }
+ }
+
private fun TestScope.createRoomListDataSource(
roomListService: FakeRoomListService = FakeRoomListService(),
roomListRoomSummaryFactory: RoomListRoomSummaryFactory = aRoomListRoomSummaryFactory(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
dateTimeObserver: FakeDateTimeObserver = FakeDateTimeObserver(),
+ analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
) = RoomListDataSource(
roomListService = roomListService,
roomListRoomSummaryFactory = roomListRoomSummaryFactory,
@@ -112,6 +273,6 @@ class RoomListDataSourceTest {
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = backgroundScope,
dateTimeObserver = dateTimeObserver,
- analyticsService = FakeAnalyticsService(),
+ analyticsService = analyticsService,
)
}
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
index 4c361b47f3..de5760c1bd 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersViewTest.kt
@@ -6,10 +6,13 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.home.impl.filters
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
@@ -17,23 +20,20 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag
-import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListFiltersViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on filters generates expected Event`() {
+ fun `clicking on filters generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setContent {
+ setContent {
RoomListFiltersView(
state = aRoomListFiltersState(eventSink = eventsRecorder),
)
}
- rule.clickOn(R.string.screen_roomlist_filter_rooms)
+ clickOn(R.string.screen_roomlist_filter_rooms)
eventsRecorder.assertList(
listOf(
RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms),
@@ -42,9 +42,9 @@ class RoomListFiltersViewTest {
}
@Test
- fun `clicking on clear filters generates expected Event`() {
+ fun `clicking on clear filters generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setContent {
+ setContent {
RoomListFiltersView(
state = aRoomListFiltersState(
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) },
@@ -52,7 +52,7 @@ class RoomListFiltersViewTest {
),
)
}
- rule.pressTag(TestTags.homeScreenClearFilters.value)
+ pressTag(TestTags.homeScreenClearFilters.value)
eventsRecorder.assertList(
listOf(
RoomListFiltersEvent.ClearSelectedFilters,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
index 6be5fe4c16..5fa2adf9d6 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenuTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.home.impl.roomlist
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R
import io.element.android.libraries.matrix.api.core.RoomId
@@ -20,23 +23,20 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
-import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListContextMenuTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on Mark as read generates expected Events`() {
+ fun `clicking on Mark as read generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(hasNewContent = true)
- rule.setRoomListContextMenu(
+ setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
- rule.clickOn(R.string.screen_roomlist_mark_as_read)
+ clickOn(R.string.screen_roomlist_mark_as_read)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideContextMenu,
@@ -46,14 +46,14 @@ class RoomListContextMenuTest {
}
@Test
- fun `clicking on Mark as unread generates expected Events`() {
+ fun `clicking on Mark as unread generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(hasNewContent = false)
- rule.setRoomListContextMenu(
+ setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
- rule.clickOn(R.string.screen_roomlist_mark_as_unread)
+ clickOn(R.string.screen_roomlist_mark_as_unread)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideContextMenu,
@@ -63,14 +63,14 @@ class RoomListContextMenuTest {
}
@Test
- fun `clicking on Leave room generates expected Events`() {
+ fun `clicking on Leave room generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(isDm = false)
- rule.setRoomListContextMenu(
+ setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
)
- rule.clickOn(CommonStrings.action_leave_room)
+ clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideContextMenu,
@@ -80,48 +80,48 @@ class RoomListContextMenuTest {
}
@Test
- fun `clicking on Report room invokes the expected callback and generates expected Event`() {
+ fun `clicking on Report room invokes the expected callback and generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
- rule.setRoomListContextMenu(
+ setRoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = true,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = callback,
)
- rule.clickOn(CommonStrings.action_report_room)
+ clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess()
}
@Test
- fun `clicking on Settings invokes the expected callback and generates expected Event`() {
+ fun `clicking on Settings invokes the expected callback and generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
- rule.setRoomListContextMenu(
+ setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
onRoomSettingsClick = callback,
)
- rule.clickOn(CommonStrings.common_settings)
+ clickOn(CommonStrings.common_settings)
eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess()
}
@Test
- fun `clicking on Favourites generates expected Event`() {
+ fun `clicking on Favourites generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam()
- rule.setRoomListContextMenu(
+ setRoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
onRoomSettingsClick = callback,
)
- rule.clickOn(CommonStrings.common_favourite)
+ clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList(
listOf(
RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, true),
@@ -129,7 +129,7 @@ class RoomListContextMenuTest {
)
}
- private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
+ private fun AndroidComposeUiTest.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false,
eventSink: (RoomListEvent) -> Unit,
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
index d7f509fda4..c8bba05e52 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListDeclineInviteMenuTest.kt
@@ -6,10 +6,12 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.home.impl.roomlist
-import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.ui.strings.CommonStrings
@@ -18,19 +20,16 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
-import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListDeclineInviteMenuTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on decline emits the expected Events`() {
+ fun `clicking on decline emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
- rule.setSafeContent {
+ setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
@@ -38,7 +37,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder,
)
}
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideDeclineInviteMenu,
@@ -48,10 +47,10 @@ class RoomListDeclineInviteMenuTest {
}
@Test
- fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
+ fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
- rule.setSafeContent {
+ setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = true,
@@ -59,16 +58,16 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder,
)
}
- rule.clickOn(CommonStrings.action_decline_and_block)
+ clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(RoomListEvent.HideDeclineInviteMenu)
eventsRecorder.assertList(expectedEvents)
}
@Test
- fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
+ fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
- rule.setSafeContent {
+ setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
@@ -76,7 +75,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder,
)
}
- rule.clickOn(CommonStrings.action_decline_and_block)
+ clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(
RoomListEvent.HideDeclineInviteMenu,
RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = true),
@@ -85,10 +84,10 @@ class RoomListDeclineInviteMenuTest {
}
@Test
- fun `clicking on cancel emits the expected Event`() {
+ fun `clicking on cancel emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
- rule.setSafeContent {
+ setSafeContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
@@ -96,7 +95,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder,
)
}
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(listOf(RoomListEvent.HideDeclineInviteMenu))
}
}
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 8402a921ca..b8d61994fa 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
@@ -6,16 +6,19 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.home.impl.roomlist
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R
@@ -32,22 +35,17 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class RoomListViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Config(qualifiers = "h1024dp")
@Test
- fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() {
+ fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setRoomListView(
+ setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
@@ -62,9 +60,9 @@ class RoomListViewTest {
}
@Test
- fun `clicking on close recovery key banner emits the expected Event`() {
+ fun `clicking on close recovery key banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setRoomListView(
+ setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
@@ -74,15 +72,15 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
- val close = rule.activity.getString(CommonStrings.action_close)
- rule.onNodeWithContentDescription(close).performClick()
+ val close = activity!!.getString(CommonStrings.action_close)
+ onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@Test
- fun `clicking on close setup key banner emits the expected Event`() {
+ fun `clicking on close setup key banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setRoomListView(
+ setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder,
@@ -92,16 +90,16 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
- val close = rule.activity.getString(CommonStrings.action_close)
- rule.onNodeWithContentDescription(close).performClick()
+ val close = activity!!.getString(CommonStrings.action_close)
+ onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
}
@Test
- fun `clicking on continue recovery key banner invokes the expected callback`() {
+ fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
ensureCalledOnce { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder,
@@ -112,17 +110,17 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertEmpty()
}
}
@Test
- fun `clicking on continue setup key banner invokes the expected callback`() {
+ fun `clicking on continue setup key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
ensureCalledOnce { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder,
@@ -131,28 +129,28 @@ class RoomListViewTest {
)
// Remove automatic initial events
eventsRecorder.clear()
- rule.clickOn(R.string.banner_set_up_recovery_submit)
+ clickOn(R.string.banner_set_up_recovery_submit)
eventsRecorder.assertEmpty()
}
}
@Test
- fun `clicking on start chat when the session has no room invokes the expected callback`() {
+ fun `clicking on start chat when the session has no room invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = aRoomListState(
eventSink = eventsRecorder,
contentState = anEmptyContentState(),
),
onCreateRoomClick = callback,
)
- rule.clickOn(CommonStrings.action_start_chat)
+ clickOn(CommonStrings.action_start_chat)
}
}
@Test
- fun `clicking on a room invokes the expected callback`() {
+ fun `clicking on a room invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
@@ -161,7 +159,7 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.ROOM
}
ensureCalledOnceWithParam(room0.roomId) { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = state,
onRoomClick = callback,
)
@@ -169,14 +167,14 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
- rule.onNodeWithText(room0.latestEvent.content().toString()).performClick()
+ onNodeWithText(room0.latestEvent.content().toString()).performClick()
}
eventsRecorder.assertEmpty()
}
@Test
- fun `clicking on a room twice invokes the expected callback only once`() {
+ fun `clicking on a room twice invokes the expected callback only once`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
@@ -185,13 +183,13 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.ROOM
}
ensureCalledOnceWithParam(room0.roomId) { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = state,
onRoomClick = callback,
)
// Remove automatic initial events
eventsRecorder.clear()
- rule.onNodeWithText(room0.latestEvent.content().toString())
+ onNodeWithText(room0.latestEvent.content().toString())
.performClick()
.performClick()
}
@@ -199,7 +197,7 @@ class RoomListViewTest {
}
@Test
- fun `long clicking on a room emits the expected Event`() {
+ fun `long clicking on a room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
@@ -207,18 +205,18 @@ class RoomListViewTest {
val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM
}
- rule.setRoomListView(
+ setRoomListView(
state = state,
)
// Remove automatic initial events
eventsRecorder.clear()
- rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
+ onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvent.ShowContextMenu(room0))
}
@Test
- fun `clicking on a room setting invokes the expected callback and emits expected Event`() {
+ fun `clicking on a room setting invokes the expected callback and emits expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val state = aRoomListState(
contextMenu = aContextMenuShown(),
@@ -226,7 +224,7 @@ class RoomListViewTest {
)
val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId
ensureCalledOnceWithParam(room0) { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = state,
onRoomSettingsClick = callback,
)
@@ -234,14 +232,14 @@ class RoomListViewTest {
// Remove automatic initial events
eventsRecorder.clear()
- rule.clickOn(CommonStrings.common_settings)
+ clickOn(CommonStrings.common_settings)
}
eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
}
@Test
- fun `clicking on accept and decline invite emits the expected Events`() {
+ fun `clicking on accept and decline invite emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val state = aRoomListState(
eventSink = eventsRecorder,
@@ -249,13 +247,13 @@ class RoomListViewTest {
val invitedRoom = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.INVITE
}
- rule.setRoomListView(state = state)
+ setRoomListView(state = state)
// Remove automatic initial events
eventsRecorder.clear()
- rule.clickOn(CommonStrings.action_accept)
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_accept)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
RoomListEvent.AcceptInvite(invitedRoom),
@@ -265,7 +263,7 @@ class RoomListViewTest {
}
}
-private fun AndroidComposeTestRule.setRoomListView(
+private fun AndroidComposeUiTest.setRoomListView(
state: RoomListState,
onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onSettingsClick: () -> Unit = EnsureNeverCalled(),
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt
index 5c1325b107..d612d765b6 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersViewTest.kt
@@ -5,34 +5,32 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.home.impl.spacefilters
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.tests.testutils.EventsRecorder
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SpaceFiltersViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on a filter with alias shows display name and alias`() {
+ fun `clicking on a filter with alias shows display name and alias`() = runAndroidComposeUiTest {
val filter = aSpaceServiceFilter(
displayName = "Test Space",
canonicalAlias = A_ROOM_ALIAS,
)
val eventsRecorder = EventsRecorder()
- rule.setSpaceFiltersView(
+ setSpaceFiltersView(
state = aSelectingSpaceFiltersState(
availableFilters = listOf(filter),
eventSink = eventsRecorder,
@@ -40,20 +38,20 @@ class SpaceFiltersViewTest {
)
// Both display name and alias should be visible
- rule.onNodeWithText(filter.spaceRoom.displayName).assertExists()
- rule.onNodeWithText(A_ROOM_ALIAS.value).assertExists()
+ onNodeWithText(filter.spaceRoom.displayName).assertExists()
+ onNodeWithText(A_ROOM_ALIAS.value).assertExists()
- rule.onNodeWithText(filter.spaceRoom.displayName).performClick()
+ onNodeWithText(filter.spaceRoom.displayName).performClick()
eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter))
}
@Test
- fun `multiple filters are displayed and clickable`() {
+ fun `multiple filters are displayed and clickable`() = runAndroidComposeUiTest {
val filter1 = aSpaceServiceFilter(displayName = "Space One")
val filter2 = aSpaceServiceFilter(displayName = "Space Two")
val eventsRecorder = EventsRecorder()
- rule.setSpaceFiltersView(
+ setSpaceFiltersView(
state = aSelectingSpaceFiltersState(
availableFilters = listOf(filter1, filter2),
eventSink = eventsRecorder,
@@ -61,17 +59,17 @@ class SpaceFiltersViewTest {
)
// Both filters should be visible
- rule.onNodeWithText(filter1.spaceRoom.displayName).assertExists()
- rule.onNodeWithText(filter2.spaceRoom.displayName).assertExists()
+ onNodeWithText(filter1.spaceRoom.displayName).assertExists()
+ onNodeWithText(filter2.spaceRoom.displayName).assertExists()
// Click on second filter
- rule.onNodeWithText(filter2.spaceRoom.displayName).performClick()
+ onNodeWithText(filter2.spaceRoom.displayName).performClick()
eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter2))
}
}
-private fun AndroidComposeTestRule.setSpaceFiltersView(
+private fun AndroidComposeUiTest.setSpaceFiltersView(
state: SpaceFiltersState,
) {
setContent {
diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt
index 696e02a0d7..8bfea2c12c 100644
--- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt
+++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/InviteData.kt
@@ -11,7 +11,6 @@ package io.element.android.features.invite.api
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
-import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.parcelize.Parcelize
diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts
index 80b98464f7..e033f2740c 100644
--- a/features/invite/impl/build.gradle.kts
+++ b/features/invite/impl/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt
index 3f8bf93afa..db2e76c1c3 100644
--- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt
+++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/acceptdecline/AcceptDeclineInviteStateProvider.kt
@@ -15,6 +15,7 @@ import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInv
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.impl.AcceptInvite
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.designsystem.preview.ROOM_NAME
import io.element.android.libraries.matrix.api.core.RoomId
open class AcceptDeclineInviteStateProvider : PreviewParameterProvider {
@@ -26,7 +27,7 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider
+
+ "No veuràs cap missatge ni invitacions a sales d\'aquest usuari."
+ "Bloqueja usuari"
+ "Denuncia aquesta sala al teu proveïdor de compte."
+ "Descriu el motiu de la denúncia…"
+ "Rebutja i bloqueja"
+ "Segur que vols rebutjar la invitació per unir-te a %1$s?"
+ "Rebutja invitació"
+ "Segur que vols rebutjar el xat privat amb %1$s?"
+ "Rebutja xat"
+ "Sense invitacions"
+ "%1$s (%2$s) t\'ha convidat"
+ "Sí, rebutja i bloqueja"
+ "Segur que vols rebutjar la invitació d\'unió a aquesta sala? Això també evitarà que %1$s et contacti i et convidi a sales."
+ "Rebutja la invitació i bloqueja"
+ "Rebutja i bloqueja"
+
diff --git a/features/invite/impl/src/main/res/values-zh/translations.xml b/features/invite/impl/src/main/res/values-zh/translations.xml
index 8c27397225..02825bca6d 100644
--- a/features/invite/impl/src/main/res/values-zh/translations.xml
+++ b/features/invite/impl/src/main/res/values-zh/translations.xml
@@ -1,18 +1,18 @@
- "您将不会看到来自该用户的任何信息或房间邀请"
+ "你将不会看到来自该用户的任何消息或房间邀请""屏蔽用户"
- "向您的帐户提供商举报此房间。"
- "描述举报的原因…"
+ "向账户提供者举报此房间。"
+ "描述举报的理由…""拒绝并屏蔽"
- "您确定要拒绝加入 %1$s 的邀请吗?"
+ "你确定要拒绝加入 %1$s 的邀请?""拒绝邀请"
- "您确定要拒绝与 %1$s 开始私聊吗?"
+ "你确定要拒绝与 %1$s 私聊?""拒绝聊天""没有邀请"
- "%1$s (%2$s)邀请了你"
- "是的,拒绝并屏蔽"
- "您确定要拒绝加入此房间的邀请吗?这也将阻止 %1$s 与您联系或邀请您加入房间。"
+ "%1$s(%2$s)邀请了你"
+ "是,拒绝并屏蔽"
+ "你确定要拒绝此房间的加入邀请?这也将阻止 %1$s 与你联系或邀请你加入房间。""拒绝邀请并屏蔽""拒绝并屏蔽"
diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
index 299fec8565..e915696de4 100644
--- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
+++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockViewTest.kt
@@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.invite.impl.declineandblock
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invite.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@@ -21,98 +24,94 @@ 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 org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DeclineAndBlockViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back invoke the expected callback`() {
+ fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setDeclineAndBlockView(
+ setDeclineAndBlockView(
aDeclineAndBlockState(
eventSink = eventsRecorder,
),
onBackClick = it
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `clicking on decline when enabled emits the expected event`() {
+ fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDeclineAndBlockView(
+ setDeclineAndBlockView(
aDeclineAndBlockState(
blockUser = true,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(DeclineAndBlockEvents.Decline)
}
@Test
- fun `clicking on decline when disabled does not emit event`() {
+ fun `clicking on decline when disabled does not emit event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
- rule.setDeclineAndBlockView(
+ setDeclineAndBlockView(
aDeclineAndBlockState(
blockUser = false,
reportRoom = false,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertEmpty()
}
@Test
- fun `clicking on block option emits the expected event`() {
+ fun `clicking on block option emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDeclineAndBlockView(
+ setDeclineAndBlockView(
aDeclineAndBlockState(
blockUser = true,
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_decline_and_block_block_user_option_title)
+ clickOn(R.string.screen_decline_and_block_block_user_option_title)
eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleBlockUser)
}
@Test
- fun `clicking on report room option emits the expected event`() {
+ fun `clicking on report room option emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDeclineAndBlockView(
+ setDeclineAndBlockView(
aDeclineAndBlockState(
reportRoom = true,
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_report_room)
+ clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleReportRoom)
}
@Test
- fun `typing text in the reason field emits the expected Event`() {
+ fun `typing text in the reason field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDeclineAndBlockView(
+ setDeclineAndBlockView(
aDeclineAndBlockState(
reportRoom = true,
reportReason = "",
eventSink = eventsRecorder,
),
)
- rule.onNodeWithText("").performTextInput("Spam!")
+ onNodeWithText("").performTextInput("Spam!")
eventsRecorder.assertSingle(DeclineAndBlockEvents.UpdateReportReason("Spam!"))
}
}
-private fun AndroidComposeTestRule.setDeclineAndBlockView(
+private fun AndroidComposeUiTest.setDeclineAndBlockView(
state: DeclineAndBlockState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/invite/test/build.gradle.kts b/features/invite/test/build.gradle.kts
index 2df267f155..080ed765bb 100644
--- a/features/invite/test/build.gradle.kts
+++ b/features/invite/test/build.gradle.kts
@@ -16,6 +16,7 @@ android {
dependencies {
implementation(libs.coroutines.core)
+ implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test)
implementation(projects.tests.testutils)
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt
index 264aafd570..0422fac4f1 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt
@@ -11,4 +11,5 @@ package io.element.android.features.invitepeople.api
interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents
+ data object ClearError : InvitePeopleEvents
}
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt
index 9d342d191f..d14042cff7 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt
@@ -9,10 +9,12 @@
package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
interface InvitePeopleState {
val canInvite: Boolean
val isSearchActive: Boolean
val sendInvitesAction: AsyncAction
+ val createRoomFromDmAction: AsyncAction
val eventSink: (InvitePeopleEvents) -> Unit
}
diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt
index ce30bcc1f6..b233ed07ef 100644
--- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt
+++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleStateProvider.kt
@@ -10,6 +10,7 @@ package io.element.android.features.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.matrix.api.core.RoomId
class InvitePeopleStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -25,6 +26,7 @@ private data class PreviewInvitePeopleState(
override val canInvite: Boolean,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction,
+ override val createRoomFromDmAction: AsyncAction,
override val eventSink: (InvitePeopleEvents) -> Unit,
) : InvitePeopleState
@@ -32,10 +34,12 @@ private fun aPreviewInvitePeopleState(
canInvite: Boolean = false,
isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction = AsyncAction.Uninitialized,
+ createRoomFromDmAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {},
) = PreviewInvitePeopleState(
canInvite = canInvite,
isSearchActive = isSearchActive,
sendInvitesAction = sendInvitesAction,
+ createRoomFromDmAction = createRoomFromDmAction,
eventSink = eventSink
)
diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts
index 390ccce7b9..2ab2fcb4a3 100644
--- a/features/invitepeople/impl/build.gradle.kts
+++ b/features/invitepeople/impl/build.gradle.kts
@@ -33,8 +33,10 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.uiUtils)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.usersearch.api)
+ implementation(projects.libraries.testtags)
implementation(libs.coil.compose)
implementation(projects.services.apperror.api)
implementation(projects.libraries.featureflag.api)
diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt
index 58b3fb67f6..44627daca3 100644
--- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt
+++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenter.kt
@@ -13,7 +13,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -37,16 +36,19 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
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.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
+import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
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.RoomMembershipState
import io.element.android.libraries.matrix.api.room.filterMembers
+import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
+import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.recent.getRecentDirectRooms
+import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserRepository
@@ -74,7 +76,6 @@ class DefaultInvitePeoplePresenter(
private val coroutineDispatchers: CoroutineDispatchers,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val appErrorStateService: AppErrorStateService,
- private val featureFlagService: FeatureFlagService,
private val matrixClient: MatrixClient,
) : InvitePeoplePresenter {
@AssistedFactory
@@ -92,8 +93,7 @@ class DefaultInvitePeoplePresenter(
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
-
- val enableKeyShareOnInvite by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
+ val createRoomFromDmAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) {
@@ -137,12 +137,7 @@ class DefaultInvitePeoplePresenter(
val selectedUserIdentities = produceState(
emptyMap().toImmutableMap(),
selectedUsers.value,
- enableKeyShareOnInvite,
) {
- if (!enableKeyShareOnInvite) {
- return@produceState
- }
-
val selected = selectedUsers.value
val cached = value
@@ -213,13 +208,19 @@ class DefaultInvitePeoplePresenter(
}
}
is InvitePeopleEvents.SendInvites -> {
- if (enableKeyShareOnInvite && unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
+ if (unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
sendInvitesAction.value = ConfirmingUnknownUserInvitation(
unknownUsers
)
} else {
room.dataOrNull()?.let {
- sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
+ sessionCoroutineScope.launch {
+ if (it.isDm()) {
+ createRoomFromDm(it, selectedUsers.value, createRoomFromDmAction)
+ } else {
+ sendInvites(it, selectedUsers.value, sendInvitesAction)
+ }
+ }
}
}
}
@@ -227,6 +228,10 @@ class DefaultInvitePeoplePresenter(
searchActive = false
queryState.clearText()
}
+ is InvitePeopleEvents.ClearError -> {
+ sendInvitesAction.value = AsyncAction.Uninitialized
+ createRoomFromDmAction.value = AsyncAction.Uninitialized
+ }
}
}
@@ -239,6 +244,7 @@ class DefaultInvitePeoplePresenter(
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
+ createRoomFromDmAction = createRoomFromDmAction.value,
suggestions = suggestions,
eventSink = ::handleEvent,
)
@@ -265,6 +271,35 @@ class DefaultInvitePeoplePresenter(
}
}
+ private fun CoroutineScope.createRoomFromDm(
+ currentRoom: JoinedRoom,
+ selectedUsers: List,
+ createRoomFromDmAction: MutableState>,
+ ) = launch {
+ createRoomFromDmAction.runUpdatingState {
+ val currentUsers = currentRoom.getMembers(limit = 100).getOrNull().orEmpty()
+ .filter { it.membership.isActive() }
+ val invitees = (currentUsers.map { it.userId } + selectedUsers.map { it.userId })
+ .filter { it != matrixClient.sessionId }
+ .distinct()
+ matrixClient.createRoom(
+ CreateRoomParameters(
+ name = null,
+ topic = null,
+ isEncrypted = true,
+ isDirect = false,
+ visibility = RoomVisibility.Private,
+ preset = RoomPreset.PRIVATE_CHAT,
+ invite = invitees,
+ avatar = null,
+ joinRuleOverride = JoinRule.Invite,
+ historyVisibilityOverride = RoomHistoryVisibility.Invited,
+ isSpace = false,
+ )
+ )
+ }
+ }
+
@JvmName("toggleUserInSelectedUsers")
private fun MutableState>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {
diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt
index 842bcf1148..46e5d9f1a5 100644
--- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt
+++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleState.kt
@@ -14,6 +14,7 @@ import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@@ -26,6 +27,7 @@ data class DefaultInvitePeopleState(
val selectedUsers: ImmutableList,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction,
+ override val createRoomFromDmAction: AsyncAction,
val suggestions: ImmutableList,
override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState
diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt
index c26b8de254..93a6e03bd3 100644
--- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt
+++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeopleStateProvider.kt
@@ -12,7 +12,13 @@ import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
+import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
+import io.element.android.libraries.designsystem.preview.USER_NAME_CAROL
+import io.element.android.libraries.designsystem.preview.USER_NAME_EVE
+import io.element.android.libraries.designsystem.preview.USER_NAME_JUSTIN
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -33,15 +39,15 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized,
+ createRoomFromDmAction: AsyncAction = AsyncAction.Uninitialized,
suggestions: List = aMatrixUserList()
.take(5)
.map { user -> anInvitableUser(matrixUser = user, isSelected = user in selectedUsers) },
@@ -125,6 +134,7 @@ private fun aDefaultInvitePeopleState(
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction,
+ createRoomFromDmAction = createRoomFromDmAction,
suggestions = suggestions.toImmutableList(),
eventSink = {},
)
diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt
index 38db9d55be..2bbd64c977 100644
--- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt
+++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -23,7 +24,7 @@ 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.pluralStringResource
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -55,7 +56,10 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
+import io.element.android.libraries.testtags.TestTags
+import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.ui.utils.strings.simplePluralStringResource
import kotlinx.collections.immutable.ImmutableList
@Composable
@@ -102,7 +106,7 @@ private fun InvitePeopleContentView(
}
InvitePeopleSearchBar(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.imePadding().fillMaxWidth(),
queryState = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
@@ -262,10 +266,19 @@ private fun InvitePeopleConfirmModal(
ModalBottomSheet(
onDismissRequest = onDismiss,
dragHandle = null,
+ scrollable = false,
) {
IconTitleSubtitleMolecule(
- title = pluralStringResource(R.plurals.screen_invite_users_confirm_dialog_title, users.size),
- subTitle = pluralStringResource(R.plurals.screen_invite_users_confirm_dialog_subtitle, users.size),
+ title = simplePluralStringResource(
+ resIdForOne = R.string.screen_invite_users_confirm_dialog_title_one_user,
+ resIdForOthers = R.string.screen_invite_users_confirm_dialog_title_mutiple_users,
+ count = users.size,
+ ),
+ subTitle = simplePluralStringResource(
+ resIdForOne = R.string.screen_invite_users_confirm_dialog_subtitle_one_user,
+ resIdForOthers = R.string.screen_invite_users_confirm_dialog_subtitle_multiple_users,
+ count = users.size,
+ ),
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
modifier = Modifier.padding(
top = 32.dp,
@@ -289,7 +302,7 @@ private fun InvitePeopleConfirmModal(
text = stringResource(CommonStrings.action_remove),
onClick = onRemove,
leadingIcon = IconSource.Vector(CompoundIcons.Close()),
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f).testTag(TestTags.confirmInviteUnknown),
)
Button(
text = stringResource(CommonStrings.action_invite),
diff --git a/features/invitepeople/impl/src/main/res/values-ca/translations.xml b/features/invitepeople/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..294d04b4a3
--- /dev/null
+++ b/features/invitepeople/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Ja és membre"
+ "Ja s\'ha convidat"
+
diff --git a/features/invitepeople/impl/src/main/res/values-cs/translations.xml b/features/invitepeople/impl/src/main/res/values-cs/translations.xml
index fa5b3aa9a9..c041433267 100644
--- a/features/invitepeople/impl/src/main/res/values-cs/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-cs/translations.xml
@@ -2,4 +2,8 @@
"Již členem""Již pozván(a)"
+ "Momentálně s těmito kontakty nemáte žádné chaty. Před pokračováním potvrďte jejich pozvání do této místnosti."
+ "Momentálně s tímto kontaktem nemáte žádné chaty. Před pokračováním potvrďte pozvání do této místnosti."
+ "Pozvat nové kontakty do této místnosti?"
+ "Pozvat nový kontakt do této místnosti?"
diff --git a/features/invitepeople/impl/src/main/res/values-da/translations.xml b/features/invitepeople/impl/src/main/res/values-da/translations.xml
index fbb1814e9f..1754ef6e0d 100644
--- a/features/invitepeople/impl/src/main/res/values-da/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-da/translations.xml
@@ -2,4 +2,8 @@
"Allerede medlem""Allerede inviteret"
+ "Du har i øjeblikket ingen chats med disse kontakter. Bekræft deres invitation til dette rum, før du fortsætter."
+ "Du har i øjeblikket ingen chats med denne kontakt. Bekræft deres invitation til dette rum, før du fortsætter."
+ "Inviter nye kontakter til dette rum?"
+ "Inviter ny kontakt til dette rum?"
diff --git a/features/invitepeople/impl/src/main/res/values-et/translations.xml b/features/invitepeople/impl/src/main/res/values-et/translations.xml
index 44484d23c3..1bdf5f780c 100644
--- a/features/invitepeople/impl/src/main/res/values-et/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-et/translations.xml
@@ -2,4 +2,8 @@
"Sa juba oled jututoa liige""Sa juba oled kutse saanud"
+ "Sul pole hetkel nende kontaktidega ühtegi vestlust. Enne jätkamist kinnita neile siia jututuppa kutse saatmine."
+ "Sul pole hetkel selle kontaktiga ühtegi vestlust. Enne jätkamist kinnita talle siia jututuppa kutse saatmine."
+ "Kas kutsud uued kontaktid siia jututuppa?"
+ "Kas kutsud uue kontakti siia jututuppa?"
diff --git a/features/invitepeople/impl/src/main/res/values-fi/translations.xml b/features/invitepeople/impl/src/main/res/values-fi/translations.xml
index e347919719..d2283c7b2a 100644
--- a/features/invitepeople/impl/src/main/res/values-fi/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-fi/translations.xml
@@ -2,4 +2,8 @@
"On jo jäsen""On jo kutsuttu"
+ "Sinulla ei ole tällä hetkellä keskusteluja näiden yhteystietojen kanssa. Vahvista kutsusi heille tähän huoneeseen ennen kuin jatkat."
+ "Sinulla ei ole tällä hetkellä keskusteluja tämän yhteystiedon kanssa. Vahvista kutsusi hänelle tähän huoneeseen ennen kuin jatkat."
+ "Kutsutaanko uusia yhteystietoja tähän huoneeseen?"
+ "Kutsutaanko uusi yhteystieto tähän huoneeseen?"
diff --git a/features/invitepeople/impl/src/main/res/values-fr/translations.xml b/features/invitepeople/impl/src/main/res/values-fr/translations.xml
index dcc16f58cf..b5df870002 100644
--- a/features/invitepeople/impl/src/main/res/values-fr/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-fr/translations.xml
@@ -2,4 +2,8 @@
"Déjà membre""Déjà invité(e)"
+ "Vous n’avez actuellement aucune conversation avec ces contacts. Veuillez confirmer leur invitation à rejoindre ce salon avant de continuer."
+ "Vous n’avez actuellement aucune conversation avec ce contact. Veuillez confirmer son invitation à rejoindre ce salon avant de continuer."
+ "Inviter de nouveaux contacts dans ce salon ?"
+ "Inviter un nouveau contact dans ce salon ?"
diff --git a/features/invitepeople/impl/src/main/res/values-hr/translations.xml b/features/invitepeople/impl/src/main/res/values-hr/translations.xml
index 66031c5fd7..471b79d709 100644
--- a/features/invitepeople/impl/src/main/res/values-hr/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-hr/translations.xml
@@ -2,4 +2,8 @@
"Već je član""Već je pozvan/a"
+ "Trenutno nemate razgovora s ovim kontaktima. Potvrdite da ih želite pozvati u ovu sobu prije nego što nastavite."
+ "Trenutno nemate razgovora s ovim kontaktom. Potvrdite da ste ga pozvali u ovu sobu prije nego što nastavite."
+ "Pozvati nove kontakte u ovu sobu?"
+ "Pozvati novi kontakt u ovu sobu?"
diff --git a/features/invitepeople/impl/src/main/res/values-hu/translations.xml b/features/invitepeople/impl/src/main/res/values-hu/translations.xml
index 16f35b018c..de2cab0d73 100644
--- a/features/invitepeople/impl/src/main/res/values-hu/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-hu/translations.xml
@@ -2,4 +2,8 @@
"Már tag""Már meghívták"
+ "Jelenleg nincsenek csevegései ezekkel a kapcsolatokkal. Mielőtt továbbmenne, erősítse meg, hogy meghívja őket ebbe a szobába."
+ "Jelenleg nincsenek beszélgetései ezzel a személlyel. A folytatás előtt erősítse meg, hogy meghívja ebbe a szobába."
+ "Új személyeket hív meg ebbe a szobába?"
+ "Új személyt hív meg ebbe a szobába?"
diff --git a/features/invitepeople/impl/src/main/res/values-it/translations.xml b/features/invitepeople/impl/src/main/res/values-it/translations.xml
index 979e42de1b..82dc6126f1 100644
--- a/features/invitepeople/impl/src/main/res/values-it/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-it/translations.xml
@@ -2,4 +2,8 @@
"Già membro""Già invitato"
+ "Al momento non hai conversazioni con questi contatti. Conferma di invitarli in questa stanza prima di continuare."
+ "Al momento non hai converszioni con questo contatto. Conferma di invitarlo in questa stanza prima di continuare."
+ "Invita nuovi contatti in questa stanza?"
+ "Invitare un nuovo contatto in questa stanza?"
diff --git a/features/invitepeople/impl/src/main/res/values-ja/translations.xml b/features/invitepeople/impl/src/main/res/values-ja/translations.xml
index 6aa1fb0370..db5b91ca2b 100644
--- a/features/invitepeople/impl/src/main/res/values-ja/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-ja/translations.xml
@@ -2,10 +2,8 @@
"既に参加しています""既に招待しています"
-
- "この連絡先とのチャットがありません。続行する前に、このルームに招待してください。"
-
-
- "このルームに新しい連絡先を追加しますか?"
-
+ "これらの人物とのチャットがありません。はじめに、招待の状況を確認してください。"
+ "この連絡先とのチャットがありません。はじめに、招待の状況を確認してください。"
+ "このルームに新しい連絡先を招待しますか?"
+ "このルームに新しい連絡先を招待しますか?"
diff --git a/features/invitepeople/impl/src/main/res/values-pl/translations.xml b/features/invitepeople/impl/src/main/res/values-pl/translations.xml
index bfd537bb4b..3e8371c1ab 100644
--- a/features/invitepeople/impl/src/main/res/values-pl/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-pl/translations.xml
@@ -2,4 +2,8 @@
"Jest już członkiem""Już zaproszony"
+ "Obecnie nie prowadzisz żadnych czatów z tymi kontaktami. Potwierdź zaproszenie, zanim przejdziesz dalej."
+ "Obecnie nie posiadasz żadnych czatów z tym kontaktem. Potwierdź zaproszenie, zanim przejdziesz dalej."
+ "Zaprosić nowe kontakty do tego pokoju?"
+ "Zaprosić nowy kontakt do tego pokoju?"
diff --git a/features/invitepeople/impl/src/main/res/values-ro/translations.xml b/features/invitepeople/impl/src/main/res/values-ro/translations.xml
index f03be4b263..40189e3186 100644
--- a/features/invitepeople/impl/src/main/res/values-ro/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-ro/translations.xml
@@ -2,4 +2,8 @@
"Deja membru""Deja invitat"
+ "În prezent, nu aveți nicio conversație cu aceste contacte. Confirmați invitarea lor în această cameră înainte de a continua."
+ "În prezent, nu aveți nicio conversație cu acest contact. Confirmați invitarea acestuia în cameră înainte de a continua."
+ "Invitați contactele noi în această cameră?"
+ "Invitați contactul nou în această cameră?"
diff --git a/features/invitepeople/impl/src/main/res/values-ru/translations.xml b/features/invitepeople/impl/src/main/res/values-ru/translations.xml
index 45e650f081..1f2455ba72 100644
--- a/features/invitepeople/impl/src/main/res/values-ru/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-ru/translations.xml
@@ -2,4 +2,8 @@
"Уже участник""Уже приглашен(а)"
+ "У тебя пока нет чатов с этими контактами. Подтверди приглашение в эту комнату, прежде чем продолжить."
+ "У вас пока нет чатов с этим контактом. Подтверди приглашение в эту комнату, прежде чем продолжить."
+ "Пригласить новых участников в эту комнату?"
+ "Пригласить нового участника в эту комнату?"
diff --git a/features/invitepeople/impl/src/main/res/values-uk/translations.xml b/features/invitepeople/impl/src/main/res/values-uk/translations.xml
index da3ac9fe5b..b7bb3c95d9 100644
--- a/features/invitepeople/impl/src/main/res/values-uk/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-uk/translations.xml
@@ -2,4 +2,8 @@
"Уже учасник""Уже запрошені"
+ "Наразі у вас немає чатів із цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."
+ "Наразі у вас немає чатів із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."
+ "Запросити нових контактів до цієї кімнати?"
+ "Запросити нового контакта до цієї кімнати?"
diff --git a/features/invitepeople/impl/src/main/res/values-zh/translations.xml b/features/invitepeople/impl/src/main/res/values-zh/translations.xml
index b1e0e953f8..7b1bb29288 100644
--- a/features/invitepeople/impl/src/main/res/values-zh/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-zh/translations.xml
@@ -2,4 +2,8 @@
"已经是成员""已邀请"
+ "你与这些联系人暂无任何聊天。请确认对方被邀请到此房间后再继续。"
+ "你与此人暂无任何聊天。请确认对方被邀请到此房间后再继续。"
+ "邀请新联系人到此房间?"
+ "邀请新联系人到此房间?"
diff --git a/features/invitepeople/impl/src/main/res/values/localazy.xml b/features/invitepeople/impl/src/main/res/values/localazy.xml
index 0515121428..aae71fe4c2 100644
--- a/features/invitepeople/impl/src/main/res/values/localazy.xml
+++ b/features/invitepeople/impl/src/main/res/values/localazy.xml
@@ -2,12 +2,8 @@
"Already a member""Already invited"
-
- "You currently don’t have any chats with this contact. Confirm inviting them to this room before continuing."
- "You currently don’t have any chats with these contacts. Confirm inviting them to this room before continuing."
-
-
- "Invite a new contact to this room?"
- "Invite new contacts to this room?"
-
+ "You currently don’t have any chats with these contacts. Confirm inviting them to this room before continuing."
+ "You currently don’t have any chats with this contact. Confirm inviting them to this room before continuing."
+ "Invite new contacts to this room?"
+ "Invite new contact to this room?"
diff --git a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt
index a1d72010f6..4e141b2c4d 100644
--- a/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt
+++ b/features/invitepeople/impl/src/test/kotlin/io/element/android/features/invitepeople/impl/DefaultInvitePeoplePresenterTest.kt
@@ -15,9 +15,6 @@ import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -323,7 +320,7 @@ internal class DefaultInvitePeoplePresenterTest {
val initialState = awaitItemAsDefault()
skipItems(1)
- val selectedUser = aMatrixUser()
+ val selectedUser = aMatrixUser(displayName = "John Doe")
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(selectedUser))
@@ -361,7 +358,7 @@ internal class DefaultInvitePeoplePresenterTest {
val initialState = awaitItemAsDefault()
skipItems(1)
- val selectedUser = aMatrixUser()
+ val selectedUser = aMatrixUser(displayName = "John Doe")
// Given a query is made
initialState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
@@ -405,10 +402,14 @@ internal class DefaultInvitePeoplePresenterTest {
val inviteUserResult = lambdaRecorder> { userId: UserId ->
Result.success(Unit)
}
+ val encryptionService = FakeEncryptionService(
+ getUserIdentityResult = { _ -> Result.success(null) },
+ )
val presenter = createDefaultInvitePeoplePresenter(
userRepository = repository,
inviteUserResult = inviteUserResult,
- coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true)
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
+ matrixClient = FakeMatrixClient(encryptionService = encryptionService),
)
presenter.test {
val initialState = awaitItem()
@@ -451,13 +452,18 @@ internal class DefaultInvitePeoplePresenterTest {
Result.failure(AN_EXCEPTION)
}
val showErrorResResult = lambdaRecorder { _, _ -> }
+
+ val encryptionService = FakeEncryptionService(
+ getUserIdentityResult = { _ -> Result.success(null) },
+ )
val presenter = createDefaultInvitePeoplePresenter(
userRepository = repository,
inviteUserResult = inviteUserResult,
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
appErrorStateService = FakeAppErrorStateService(
showErrorResResult = showErrorResResult,
- )
+ ),
+ matrixClient = FakeMatrixClient(encryptionService = encryptionService),
)
presenter.test {
val initialState = awaitItem()
@@ -534,7 +540,7 @@ internal class DefaultInvitePeoplePresenterTest {
}
@Test
- fun `present - suggestions are loaded from recent direct rooms`() = runTest {
+ fun `present - suggestions are loaded from recent DM rooms`() = runTest {
val dmRoomId = RoomId("!dm_room:server.org")
val otherUserId = UserId("@frank:server.org")
val matrixClient = FakeMatrixClient(sessionId = A_USER_ID).apply {
@@ -548,7 +554,7 @@ internal class DefaultInvitePeoplePresenterTest {
roomId = dmRoomId,
initialRoomInfo = aRoomInfo(
id = dmRoomId,
- isDirect = true,
+ isDm = true,
activeMembersCount = 2,
currentUserMembership = CurrentUserMembership.JOINED,
),
@@ -585,7 +591,7 @@ internal class DefaultInvitePeoplePresenterTest {
roomId = dmRoomId,
initialRoomInfo = aRoomInfo(
id = dmRoomId,
- isDirect = true,
+ isDm = true,
activeMembersCount = 2,
currentUserMembership = CurrentUserMembership.JOINED,
),
@@ -632,15 +638,11 @@ internal class DefaultInvitePeoplePresenterTest {
val encryptionService = FakeEncryptionService(
getUserIdentityResult = getUserIdentityResult
)
- val featureFlagService = FakeFeatureFlagService().apply {
- setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
- }
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
inviteUserResult = inviteUserResult,
matrixClient = FakeMatrixClient(encryptionService = encryptionService),
- featureFlagService = featureFlagService
)
presenter.test {
val initialState = awaitItem()
@@ -703,15 +705,11 @@ internal class DefaultInvitePeoplePresenterTest {
val encryptionService = FakeEncryptionService(
getUserIdentityResult = getUserIdentityResult
)
- val featureFlagService = FakeFeatureFlagService().apply {
- setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
- }
val presenter = createDefaultInvitePeoplePresenter(
userRepository = repository,
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClient = FakeMatrixClient(encryptionService = encryptionService),
- featureFlagService = featureFlagService
)
presenter.test {
val initialState = awaitItemAsDefault()
@@ -790,14 +788,10 @@ internal class DefaultInvitePeoplePresenterTest {
val encryptionService = FakeEncryptionService(
getUserIdentityResult = getUserIdentityResult
)
- val featureFlagService = FakeFeatureFlagService().apply {
- setFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite, true)
- }
val presenter = createDefaultInvitePeoplePresenter(
coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixClient = FakeMatrixClient(encryptionService = encryptionService),
- featureFlagService = featureFlagService
)
presenter.test {
val initialState = awaitItem()
@@ -837,6 +831,54 @@ internal class DefaultInvitePeoplePresenterTest {
}
}
+ @Test
+ fun `present - inviting someone to a DM creates a new room`() = runTest {
+ val alice = aMatrixUser("@alice:example.com")
+
+ val matrixClient = FakeMatrixClient(
+ encryptionService = FakeEncryptionService(
+ getUserIdentityResult = lambdaRecorder { userId: UserId ->
+ Result.success(IdentityState.Pinned)
+ }
+ )
+ )
+ val presenter = createDefaultInvitePeoplePresenter(
+ coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
+ matrixClient = matrixClient,
+ joinedRoom = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ initialRoomInfo = aRoomInfo(isDm = true),
+ getMembersResult = { Result.success(listOf(aRoomMember(userId = alice.userId, membership = RoomMembershipState.JOIN))) },
+ )
+ )
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ skipItems(1)
+
+ // We want to add a new user to a DM
+ initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
+
+ // And we send the invites
+ initialState.eventSink(InvitePeopleEvents.SendInvites)
+
+ skipItems(1)
+
+ awaitItemAsDefault().run {
+ assertThat(canInvite).isTrue()
+ assertThat(sendInvitesAction.isUninitialized()).isTrue()
+ // Inviting to a DM should trigger the creation of a new room
+ assertThat(createRoomFromDmAction.isLoading()).isTrue()
+ }
+
+ awaitItemAsDefault().run {
+ assertThat(sendInvitesAction.isUninitialized()).isTrue()
+ // Once the room is created, the action should be successful
+ assertThat(createRoomFromDmAction.isSuccess()).isTrue()
+ }
+ }
+ }
+
private suspend fun FakeUserRepository.emitStateWithUsers(
users: List,
isSearching: Boolean = false
@@ -878,7 +920,6 @@ fun TestScope.createDefaultInvitePeoplePresenter(
userRepository: UserRepository = FakeUserRepository(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
appErrorStateService: AppErrorStateService = FakeAppErrorStateService(),
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
matrixClient: MatrixClient = FakeMatrixClient(),
): DefaultInvitePeoplePresenter {
return DefaultInvitePeoplePresenter(
@@ -888,7 +929,6 @@ fun TestScope.createDefaultInvitePeoplePresenter(
coroutineDispatchers = coroutineDispatchers,
sessionCoroutineScope = backgroundScope,
appErrorStateService = appErrorStateService,
- featureFlagService = featureFlagService,
matrixClient = matrixClient,
)
}
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
index 1e685d3f5a..d257952209 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt
@@ -44,7 +44,6 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembershipDetails
import io.element.android.libraries.matrix.api.room.RoomType
-import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
index 7e5142321a..4bfa741321 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt
@@ -15,6 +15,9 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.ROOM_NAME
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
+import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
@@ -134,8 +137,8 @@ open class JoinRoomStateProvider : PreviewParameterProvider {
joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(
banSender = InviteSender(
userId = UserId("@alice:domain"),
- displayName = "Alice",
- avatarData = AvatarData("alice", "Alice", size = AvatarSize.InviteSender),
+ displayName = USER_NAME_ALICE,
+ avatarData = AvatarData("alice", USER_NAME_ALICE, size = AvatarSize.InviteSender),
membershipChangeReason = "spamming"
),
reason = "spamming",
@@ -222,7 +225,7 @@ fun aJoinRoomState(
internal fun anInviteSender(
userId: UserId = UserId("@bob:domain"),
- displayName: String = "Bob",
+ displayName: String = USER_NAME_BOB,
avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
membershipChangeReason: String? = null,
) = InviteSender(
@@ -234,7 +237,7 @@ internal fun anInviteSender(
internal fun anInviteData(
roomId: RoomId = A_ROOM_ID,
- roomName: String = "Room name",
+ roomName: String = ROOM_NAME,
isDm: Boolean = false,
) = InviteData(
roomId = roomId,
diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
index 35cfbb1594..977308178d 100644
--- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
+++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt
@@ -609,6 +609,7 @@ private fun JoinRoomTopBar(
val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier
.clip(roundedCornerShape)
+ .semantics { heading() }
if (contentState.name != null) {
Row(
modifier = titleModifier,
@@ -621,10 +622,7 @@ private fun JoinRoomTopBar(
)
Text(
modifier = Modifier
- .padding(horizontal = 8.dp)
- .semantics {
- heading()
- },
+ .padding(horizontal = 8.dp),
text = contentState.name,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
diff --git a/features/joinroom/impl/src/main/res/values-ca/translations.xml b/features/joinroom/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..e26c33d40f
--- /dev/null
+++ b/features/joinroom/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,32 @@
+
+
+ "Has estat bandejat per %1$s."
+ "T\'han bandejat"
+ "Motiu: %1$s."
+ "Cancel·la la sol·licitud"
+ "Sí, cancel·la"
+ "Segur que vols cancel·lar la teva sol·licitud d\'unió a aquesta sala?"
+ "Cancel·la la sol·licitud d\'unió"
+ "Sí, rebutja i bloqueja"
+ "Segur que vols rebutjar la invitació d\'unió a aquesta sala? Això també evitarà que %1$s et contacti i et convidi a sales."
+ "Rebutja la invitació i bloqueja"
+ "Rebutja i bloqueja"
+ "Ha fallat la unió"
+ "O t\'han de convidar per unir-te o hi pot haver restriccions d\'accés."
+ "Oblida"
+ "Per unir-te, necessites una invitació"
+ "Uneix-te"
+ "Pot ser que t\'hagin de convidar o hagis de ser membre d\'un espai per unir-t\'hi."
+ "Envia sol·licitud d\'unió"
+ "Missatge (opcional)"
+ "Rebràs una invitació per unir-te a la sala si la teva sol·licitud és acceptada."
+ "Sol·licitud d\'unió enviada"
+ "No s\'ha pogut mostrar la vista prèvia de la sala. Pot ser degut a problemes de xarxa o del servidor."
+ "No s\'ha pogut mostrar la vista prèvia de la sala"
+ "%1$s encara no admet els espais. Pots accedir-hi a través del navegador web."
+ "Espais encara no compatibles"
+ "Clic al botó següent i s\'avisarà a un administrador de sala. Podràs unir-te un cop t\'hagi aprovat."
+ "Per poder veure l\'historial de missatges has de ser un membre de la sala."
+ "Vols unir-te a aquesta sala?"
+ "Vista prèvia no disponible"
+
diff --git a/features/joinroom/impl/src/main/res/values-uk/translations.xml b/features/joinroom/impl/src/main/res/values-uk/translations.xml
index ec2a24950a..1a83a6ca8c 100644
--- a/features/joinroom/impl/src/main/res/values-uk/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-uk/translations.xml
@@ -15,6 +15,7 @@
"Вам потрібно отримати запрошення, щоб приєднатися, інакше доступ може бути обмежений.""Забути""Вам потрібне запрошення, щоб приєднатися"
+ "Запрошено користувачем""Доєднатися""Можливо, вам знадобиться отримати запрошення або стати учасником простору, щоб приєднатися.""Постукати, щоб приєднатися"
diff --git a/features/joinroom/impl/src/main/res/values-vi/translations.xml b/features/joinroom/impl/src/main/res/values-vi/translations.xml
index cb9258b308..bf5c33ba66 100644
--- a/features/joinroom/impl/src/main/res/values-vi/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-vi/translations.xml
@@ -1,5 +1,7 @@
+ "Bạn đã bị cấm bởi %1$s ."
+ "Bạn đã bị cấm""Hủy yêu cầu""Có, hủy""Bạn có chắc chắn muốn hủy yêu cầu tham gia phòng này không?"
diff --git a/features/joinroom/impl/src/main/res/values-zh/translations.xml b/features/joinroom/impl/src/main/res/values-zh/translations.xml
index d38f9d3427..e4de8dfec4 100644
--- a/features/joinroom/impl/src/main/res/values-zh/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-zh/translations.xml
@@ -1,34 +1,34 @@
- "您已被 %1$s 封禁。"
+ "你已被 %1$s 封禁。""你已被此房间封禁""理由:%1$s。"
- "取消请求"
- "是的,取消"
- "您确定要取消加入此房间的请求吗?"
+ "取消申请"
+ "是,取消"
+ "你确定要取消加入此房间的申请?""取消加入申请"
- "是的,拒绝并屏蔽"
- "您确定要拒绝加入此房间的邀请吗?这也将阻止 %1$s 与您联系或邀请您加入房间。"
+ "是,拒绝并屏蔽"
+ "你确定要拒绝此房间的加入邀请?这也将阻止 %1$s 与你联系或邀请你加入房间。""拒绝邀请并屏蔽""拒绝并屏蔽""加入失败"
- "您需要被邀请加入,否则可能会受到访问限制。"
+ "你需要被邀请才能加入,否则可能会遭遇访问限制。""忘记"
- "您需要邀请才能加入"
+ "你需要被邀请才能加入""受邀于""加入"
- "您可能需要受到邀请或成为某个空间的成员才能加入。"
- "加入聊天室"
- "允许的字符数量 %2$d中的%1$d"
+ "你可能需要被邀请或成为某个空间的成员才能加入。"
+ "加入房间"
+ "允许的字符数量共 %2$d 个,当前为 %1$d 个""消息(可选)"
- "如果您的请求被接受,您将收到加入房间的邀请。"
- "加入请求已发送"
+ "如果你的申请被批准,你将收到加入房间的邀请。"
+ "加入申请已发送""无法显示房间预览。这可能是由于网络或服务器问题造成的。""无法显示此房间预览"
- "%1$s 尚不支持空间。您可以通过 Web 端访问空间"
- "空间尚不支持"
- "点击下面的按钮,系统将通知聊天室管理员。获得批准后将能够加入对话。"
- "只有聊天室成员才能查看消息历史记录。"
- "想加入此聊天室吗?"
+ "%1$s 暂不支持空间。你可以通过 Web 客户端访问空间。"
+ "空间尚未受到支持"
+ "点击以下按钮以通知房间管理员。获得批准后你将能加入对话。"
+ "只有房间成员才能查看消息历史。"
+ "想加入此房间吗?""预览不可用"
diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt
index 0a3b1ca3c6..e60d7da691 100644
--- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt
+++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.joinroom.impl
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.test.anInviteData
@@ -26,116 +29,112 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class JoinRoomViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back invoke the expected callback`() {
+ fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
eventSink = eventsRecorder,
),
onBackClick = it
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `clicking on Join room on CanJoin room emits the expected Event`() {
+ fun `clicking on Join room on CanJoin room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin),
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_join_room_join_action)
+ clickOn(R.string.screen_join_room_join_action)
eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom)
}
@Test
- fun `clicking on Knock room on CanKnock room emits the expected Event`() {
+ fun `clicking on Knock room on CanKnock room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockMessage = "Knock knock",
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_join_room_knock_action)
+ clickOn(R.string.screen_join_room_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom)
}
@Test
- fun `clicking on closing Knock error emits the expected Event`() {
+ fun `clicking on closing Knock error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_ok)
+ clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
}
@Test
- fun `clicking on cancel knock request emit the expected Event`() {
+ fun `clicking on cancel knock request emit the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_join_room_cancel_knock_action)
+ clickOn(R.string.screen_join_room_cancel_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.CancelKnock(true))
}
@Test
- fun `clicking on closing Cancel Knock error emits the expected Event`() {
+ fun `clicking on closing Cancel Knock error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
cancelKnockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_ok)
+ clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
}
@Test
- fun `clicking on closing Join error emits the expected Event`() {
+ fun `clicking on closing Join error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
joinAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_ok)
+ clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
}
@Test
- fun `when joining room is successful, the expected callback is invoked`() {
+ fun `when joining room is successful, the expected callback is invoked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
joinAction = AsyncAction.Success(Unit),
eventSink = eventsRecorder,
@@ -146,53 +145,55 @@ class JoinRoomViewTest {
}
@Test
- fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() {
+ fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val inviteData = anInviteData()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_accept)
+ clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite(inviteData))
}
@Test
- fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() {
+ fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val inviteData = anInviteData()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, false))
}
@Test
fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and can report room, the expected callback is invoked`() {
- val eventsRecorder = EventsRecorder(expectEvents = false)
- val inviteData = anInviteData()
- val joinRoomState = aJoinRoomState(
- contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())),
- canReportRoom = true,
- eventSink = eventsRecorder,
- )
- ensureCalledOnceWithParam(inviteData) {
- rule.setJoinRoomView(
- state = joinRoomState,
- onDeclineInviteAndBlockUser = it,
+ runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder(expectEvents = false)
+ val inviteData = anInviteData()
+ val joinRoomState = aJoinRoomState(
+ contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())),
+ canReportRoom = true,
+ eventSink = eventsRecorder,
)
- rule.clickOn(R.string.screen_join_room_decline_and_block_button_title)
+ ensureCalledOnceWithParam(inviteData) {
+ setJoinRoomView(
+ state = joinRoomState,
+ onDeclineInviteAndBlockUser = it,
+ )
+ clickOn(R.string.screen_join_room_decline_and_block_button_title)
+ }
}
}
@Test
- fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() {
+ fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val inviteData = anInviteData()
val joinRoomState = aJoinRoomState(
@@ -200,29 +201,29 @@ class JoinRoomViewTest {
canReportRoom = false,
eventSink = eventsRecorder,
)
- rule.setJoinRoomView(state = joinRoomState)
- rule.clickOn(R.string.screen_join_room_decline_and_block_button_title)
+ setJoinRoomView(state = joinRoomState)
+ clickOn(R.string.screen_join_room_decline_and_block_button_title)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, true))
}
@Test
- fun `clicking on Retry when an error occurs emits the expected Event`() {
+ fun `clicking on Retry when an error occurs emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aFailureContentState(),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_retry)
+ clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent)
}
@Test
- fun `clicking on ok when user is unauthorized the expected callback`() {
+ fun `clicking on ok when user is unauthorized the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(),
joinAction = AsyncAction.Failure(JoinRoom.Failures.UnauthorizedJoin),
@@ -230,25 +231,25 @@ class JoinRoomViewTest {
),
onBackClick = it
)
- rule.clickOn(CommonStrings.action_ok)
+ clickOn(CommonStrings.action_ok)
}
}
@Test
- fun `clicking on forget when user is banned invokes the expected callback`() {
+ fun `clicking on forget when user is banned invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setJoinRoomView(
+ setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)),
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_join_room_forget_action)
+ clickOn(R.string.screen_join_room_forget_action)
eventsRecorder.assertSingle(JoinRoomEvents.ForgetRoom)
}
}
-private fun AndroidComposeTestRule.setJoinRoomView(
+private fun AndroidComposeUiTest.setJoinRoomView(
state: JoinRoomState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onJoinSuccess: () -> Unit = EnsureNeverCalled(),
diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts
index 6f030479f5..e6a1a30167 100644
--- a/features/knockrequests/impl/build.gradle.kts
+++ b/features/knockrequests/impl/build.gradle.kts
@@ -33,9 +33,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.designsystem)
- implementation(projects.libraries.featureflag.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.libraries.featureflag.test)
}
diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt
index 67f1aaae8f..7210e783fe 100644
--- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt
+++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt
@@ -11,6 +11,9 @@ package io.element.android.features.knockrequests.impl.banner
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
+import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
+import io.element.android.libraries.designsystem.preview.USER_NAME_CHARLIE
import kotlinx.collections.immutable.toImmutableList
class KnockRequestsBannerStateProvider : PreviewParameterProvider {
@@ -29,15 +32,15 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider
perms.knockRequestPermissions()
},
- isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock),
- coroutineScope = room.roomCoroutineScope
+ 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 98570e6b28..00e0a30563 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
@@ -12,7 +12,6 @@ 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
-import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
@@ -28,26 +27,20 @@ import kotlinx.coroutines.supervisorScope
class KnockRequestsService(
knockRequestsFlow: Flow>,
permissionsFlow: Flow,
- isKnockFeatureEnabledFlow: Flow,
coroutineScope: CoroutineScope,
) {
// Keep track of the knock requests that have been handled, so we don't have to wait for sync to remove them.
private val handledKnockRequestIds = MutableStateFlow>(emptySet())
val knockRequestsFlow = combine(
- isKnockFeatureEnabledFlow,
knockRequestsFlow,
handledKnockRequestIds,
- ) { isKnockEnabled, knockRequests, handledKnockIds ->
- if (!isKnockEnabled) {
- AsyncData.Success(persistentListOf())
- } else {
- val presentableKnockRequests = knockRequests
- .filter { it.eventId !in handledKnockIds }
- .map { inner -> KnockRequestWrapper(inner) }
- .toImmutableList()
- AsyncData.Success(presentableKnockRequests)
- }
+ ) { knockRequests, handledKnockIds ->
+ val presentableKnockRequests = knockRequests
+ .filter { it.eventId !in handledKnockIds }
+ .map { inner -> KnockRequestWrapper(inner) }
+ .toImmutableList()
+ AsyncData.Success(presentableKnockRequests)
}.stateIn(coroutineScope, SharingStarted.Lazily, AsyncData.Loading())
val permissionsFlow = permissionsFlow.stateIn(
diff --git a/features/knockrequests/impl/src/main/res/values-ca/translations.xml b/features/knockrequests/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..bdf1cac21b
--- /dev/null
+++ b/features/knockrequests/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,35 @@
+
+
+ "Sí, accepta-les totes"
+ "Segur que vols acceptar totes les sol·licituds d\'unió?"
+ "Accepta totes les sol·licituds"
+ "Accepta-les totes"
+ "No s\'han pogut acceptar totes les sol·licituds. Vols tornar-ho a intentar?"
+ "No s\'han pogut acceptar totes les sol·licituds"
+ "Acceptant totes les sol·licituds d\'unió"
+ "No s\'ha pogut acceptar la sol·licitud. Vols tornar-ho a intentar?"
+ "No s\'ha pogut acceptar la sol·licitud"
+ "Acceptant sol·licitud d\'unió"
+ "Sí, rebutja i bandeja"
+ "Segur que vols rebutjar i bandejar %1$s? L\'usuari no podrà sol·licitar de nou l\'accés d\'unió a aquesta sala."
+ "Rebutja i bandeja l\'accés"
+ "Rebutjant i bandejant l\'accés"
+ "Sí, rebutja"
+ "Segur que vols rebutjar %1$s d\'unir-se a aquesta sala?"
+ "Rebutja l\'accés"
+ "Rebutja i bandeja"
+ "No s\'ha pogut rebutjar la sol·licitud. Vols tornar-ho a intentar?"
+ "No s\'ha pogut rebutjar la sol·licitud"
+ "Rebutjant sol·licitud d\'unió"
+ "Quan algú demani unir-se a la sala, aquí podràs veure la sol·licitud."
+ "No hi ha sol·licituds d\'unió pendents"
+ "Carregant sol·licituds d\'unió…"
+ "Sol·licituds d\'unió"
+
+ "%1$s +%2$d altre volen unir-se a la sala"
+ "%1$s +%2$d altres volen unir-se a la sala"
+
+ "Veure totes"
+ "Accepta"
+ "%1$s vol unir-se a aquesta sala"
+
diff --git a/features/knockrequests/impl/src/main/res/values-vi/translations.xml b/features/knockrequests/impl/src/main/res/values-vi/translations.xml
index a80aa6fbb8..ab7c39c2ef 100644
--- a/features/knockrequests/impl/src/main/res/values-vi/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-vi/translations.xml
@@ -24,6 +24,9 @@
"Khi ai đó xin vào phòng, bạn sẽ thấy yêu cầu ở đây.""Không có yêu cầu tham gia nào đang chờ xử lý""Đang tải các yêu cầu tham gia…"
+
+ "%1$s + %2$d người khác muốn tham gia phòng này"
+ "Đồng ý""Xem"
diff --git a/features/knockrequests/impl/src/main/res/values-zh/translations.xml b/features/knockrequests/impl/src/main/res/values-zh/translations.xml
index 08718dfc5a..d8b8f76b13 100644
--- a/features/knockrequests/impl/src/main/res/values-zh/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-zh/translations.xml
@@ -1,32 +1,32 @@
- "是的,全部接受"
- "您确定要接受所有加入请求吗?"
- "接受所有请求"
+ "是,全部接受"
+ "你确定要接受所有加入申请?"
+ "接受所有申请""全部接受"
- "我们无法接受所有请求。是否要再试一次?"
- "无法接受所有请求"
- "接受所有加入请求"
- "我们无法接受此请求。是否要再试一次?"
- "无法接受请求"
- "接受加入请求"
- "是的,拒绝并禁止"
- "您确定要拒绝并禁止吗%1$s?该用户将无法再次请求加入该房间。"
+ "我们无法接受所有申请。是否重试?"
+ "无法接受所有申请"
+ "接受所有加入申请"
+ "我们无法接受此申请。是否重试?"
+ "无法接受申请"
+ "接受加入申请"
+ "是,拒绝并禁止"
+ "你确定要拒绝并封禁 %1$s?该用户将无法再次申请加入该房间。""拒绝并禁止访问""拒绝并禁止访问"
- "是的,拒绝"
- "您确定要拒绝 %1$s 加入此房间的请求吗?"
+ "是,拒绝"
+ "你确定要拒绝 %1$s 加入此房间的申请?""拒绝访问"
- "拒绝和禁止"
- "我们无法拒绝此请求。是否要再试一次?"
- "拒绝请求失败"
- "拒绝加入请求"
- "当有人请求加入房间时,您将能够在这里看到他们的请求。"
- "没有待处理的加入请求"
- "正在加载加入请求…"
+ "拒绝并封禁"
+ "我们无法拒绝此申请。是否重试?"
+ "拒绝申请失败"
+ "拒绝加入申请"
+ "当有人申请加入房间时,你将能够在这里看到其申请。"
+ "暂无待处理的加入申请"
+ "正在加载加入申请…""申请加入"
- "%1$s+ %2$d 其他人想加入这个房间"
+ "%1$s、%2$d 及其他人想加入此房间""查看全部""接受"
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 3161d3e81f..1595fcdbd9 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
@@ -28,18 +28,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) class KnockRequestsBannerPresenterTest {
- @Test
- fun `present - when feature is disabled then the banner should be hidden`() = runTest {
- val knockRequests = flowOf(listOf(FakeKnockRequest()))
- val presenter = createKnockRequestsBannerPresenter(isFeatureEnabled = false, knockRequestsFlow = knockRequests)
- presenter.test {
- skipItems(1)
- awaitItem().also { state ->
- assertThat(state.isVisible).isFalse()
- }
- }
- }
-
@Test
fun `present - when empty knock request list then the banner should be hidden`() = runTest {
val knockRequests = flowOf(emptyList())
@@ -229,12 +217,10 @@ import org.junit.Test
private fun TestScope.createKnockRequestsBannerPresenter(
knockRequestsFlow: Flow> = flowOf(emptyList()),
canAcceptKnockRequests: Boolean = true,
- isFeatureEnabled: Boolean = true,
): KnockRequestsBannerPresenter {
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
- isKnockFeatureEnabledFlow = flowOf(isFeatureEnabled),
permissionsFlow = flowOf(KnockRequestPermissions(canAcceptKnockRequests, canAcceptKnockRequests, canAcceptKnockRequests)),
)
return KnockRequestsBannerPresenter(
diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt
index a9fea0905e..fc1600d8c8 100644
--- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt
+++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerViewTest.kt
@@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.knockrequests.impl.banner
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
@@ -21,35 +24,30 @@ 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 org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KnockRequestsBannerViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on view on single request invoke the expected callback`() {
+ fun `clicking on view on single request invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setKnockRequestsBannerView(
+ setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
onViewRequestsClick = it
)
- rule.clickOn(R.string.screen_room_single_knock_request_view_button_title)
+ clickOn(R.string.screen_room_single_knock_request_view_button_title)
}
}
@Test
- fun `clicking on view all when multiple requests invoke the expected callback`() {
+ fun `clicking on view all when multiple requests invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setKnockRequestsBannerView(
+ setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
knockRequests = listOf(
aKnockRequestPresentable(displayName = "Alice"),
@@ -60,37 +58,37 @@ class KnockRequestsBannerViewTest {
),
onViewRequestsClick = it
)
- rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title)
+ clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title)
}
}
@Test
- fun `clicking on accept on a single request emit the expected event`() {
+ fun `clicking on accept on a single request emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setKnockRequestsBannerView(
+ setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_accept)
+ clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest)
}
@Test
- fun `clicking on dismiss emit the expected event`() {
+ fun `clicking on dismiss emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setKnockRequestsBannerView(
+ setKnockRequestsBannerView(
state = aKnockRequestsBannerState(
eventSink = eventsRecorder,
),
)
- val close = rule.activity.getString(CommonStrings.action_close)
- rule.onNodeWithContentDescription(close).performClick()
+ val close = activity!!.getString(CommonStrings.action_close)
+ onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss)
}
}
-private fun AndroidComposeTestRule.setKnockRequestsBannerView(
+private fun AndroidComposeUiTest.setKnockRequestsBannerView(
state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit = EnsureNeverCalled(),
) {
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 7102b01773..209e67cadf 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
@@ -298,7 +298,6 @@ internal fun TestScope.createKnockRequestsListPresenter(
val knockRequestsService = KnockRequestsService(
knockRequestsFlow = knockRequestsFlow,
coroutineScope = backgroundScope,
- isKnockFeatureEnabledFlow = flowOf(true),
permissionsFlow = flowOf(KnockRequestPermissions(canAccept, canDecline, canBan)),
)
return KnockRequestsListPresenter(knockRequestsService = knockRequestsService)
diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt
index 188dcc7e56..14cac7a9b7 100644
--- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt
+++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.knockrequests.impl.list
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
@@ -23,90 +26,86 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.persistentListOf
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KnockRequestsListViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back invoke the expected callback`() {
+ fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
eventSink = eventsRecorder,
),
onBackClick = it
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `clicking on accept emit the expected event`() {
+ fun `clicking on accept emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequest = aKnockRequestPresentable()
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_accept)
+ clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest))
}
@Test
- fun `clicking on decline emit the expected event`() {
+ fun `clicking on decline emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequest = aKnockRequestPresentable()
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest))
}
@Test
- fun `clicking on decline and ban emit the expected event`() {
+ fun `clicking on decline and ban emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequest = aKnockRequestPresentable()
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title)
+ clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest))
}
@Test
- fun `clicking on accept all emit the expected event`() {
+ fun `clicking on accept all emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title)
+ clickOn(R.string.screen_knock_requests_list_accept_all_button_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll)
}
@Test
- fun `retry on async view retry emit the expected event`() {
+ fun `retry on async view retry emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
@@ -114,15 +113,15 @@ class KnockRequestsListViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_retry)
+ clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction)
}
@Test
- fun `canceling async view emit the expected event`() {
+ fun `canceling async view emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
@@ -130,15 +129,15 @@ class KnockRequestsListViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction)
}
@Test
- fun `confirming async view emit the expected event`() {
+ fun `confirming async view emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
- rule.setKnockRequestsListView(
+ setKnockRequestsListView(
aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.ConfirmingNoParams,
@@ -146,12 +145,12 @@ class KnockRequestsListViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title)
+ clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction)
}
}
-private fun AndroidComposeTestRule.setKnockRequestsListView(
+private fun AndroidComposeUiTest.setKnockRequestsListView(
state: KnockRequestsListState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/leaveroom/api/src/main/res/values-ca/translations.xml b/features/leaveroom/api/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..870e0c35f2
--- /dev/null
+++ b/features/leaveroom/api/src/main/res/values-ca/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Segur que vols sortir d\'aquest xat? El xat no és públic i no t\'hi podràs tornar a unir sense una invitació."
+ "Segur que vols sortir d\'aquesta sala? N\'ets l\'única persona. Si en surts, ningú s\'hi podrà unir i tu tampoc."
+ "Segur que vols sortir d\'aquesta sala? La sala no és pública i no podràs tornar a unir-t\'hi sense una invitació."
+ "Segur que vols sortir de la sala?"
+
diff --git a/features/leaveroom/api/src/main/res/values-fa/translations.xml b/features/leaveroom/api/src/main/res/values-fa/translations.xml
index 167070891f..ec2cd89314 100644
--- a/features/leaveroom/api/src/main/res/values-fa/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-fa/translations.xml
@@ -1,5 +1,6 @@
+ "آیا مطمئنید که میخواهید این مکالمه را ترک کنید؟ این مکالمه عمومی نیست و بدون دعوت نمیتوانید دوباره به آن بپیوندید.""مطمئنید که میخواهید این اتاق را ترک کنید؟ تنها فرد اینجا هستید. در صورت ترک، هیچکسی از جمله خودتان در آینده نخواهد توانست به آن بپیوندد.""مطمئنید که میخواهید این اتاق را ترک کنید؟ این اتاق عمومی نبوده قادر نخواهید بود بدون دعوت دوباره بپیوندید.""گزینش مالکان"
diff --git a/features/leaveroom/api/src/main/res/values-vi/translations.xml b/features/leaveroom/api/src/main/res/values-vi/translations.xml
index d25a7e34b2..430ffa0ea3 100644
--- a/features/leaveroom/api/src/main/res/values-vi/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-vi/translations.xml
@@ -3,5 +3,8 @@
"Bạn có chắc chắn muốn rời khỏi cuộc trò chuyện này không? Cuộc trò chuyện này không công khai và bạn sẽ không thể tham gia lại nếu không được mời.""Bạn có chắc chắn muốn rời khỏi phòng này không? Bạn là người duy nhất ở đây. Nếu bạn rời đi, sẽ không ai có thể tham gia nữa, kể cả bạn.""Bạn có chắc chắn muốn rời khỏi phòng này không? Phòng này không công khai và bạn sẽ không thể tham gia lại nếu không có lời mời."
+ "Chọn chủ sở hữu"
+ "Bạn là chủ sở hữu duy nhất của căn phòng này. Bạn cần chuyển quyền sở hữu cho người khác trước khi rời khỏi phòng."
+ "Chuyển quyền sở hữu""Bạn có chắc chắn muốn rời khỏi phòng không?"
diff --git a/features/leaveroom/api/src/main/res/values-zh/translations.xml b/features/leaveroom/api/src/main/res/values-zh/translations.xml
index 6b7f17558b..a4c920a221 100644
--- a/features/leaveroom/api/src/main/res/values-zh/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-zh/translations.xml
@@ -1,10 +1,10 @@
- "您确定要离开此对话吗?此对话不公开,未经邀请您将无法重新加入。"
- "确定要离开此聊天室吗?此处只有你一个人。如果离开此聊天室,包括你在内的所有人都将无法进入。"
- "确定要离开此聊天室吗?此聊天室不公开,没有邀请你将无法重新加入。"
+ "你确定要离开此对话?此对话不公开,你将无法在未经邀请的情况下重新加入。"
+ "确定要离开此房间?此处只有你一个人。如果离开,包括你在内的所有人都将无法加入。"
+ "确定要离开此房间吗?此房间不公开,没有邀请你将无法重新加入。""选择所有者"
- "您是本房间的唯一所有者。离开房间前,您需要将所有权转移给他人。"
+ "你是此房间的唯一所有者。离开前需要转让所有权给他人。""转让所有权"
- "确定要离开聊天室吗?"
+ "确定要离开房间?"
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 6455b45659..d11dc7e7e8 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
@@ -24,7 +24,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomMember
-import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import kotlinx.coroutines.CoroutineScope
diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt
index 59d2c1ce23..90b7f2369f 100644
--- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt
+++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveBaseRoomPresenterTest.kt
@@ -115,7 +115,7 @@ class LeaveBaseRoomPresenterTest {
givenGetRoomResult(
roomId = A_ROOM_ID,
result = FakeBaseRoom().apply {
- givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 2))
+ givenRoomInfo(aRoomInfo(isDm = true, activeMembersCount = 2))
},
)
}
diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts
index 9c1aa9e990..adbec91e6a 100644
--- a/features/linknewdevice/impl/build.gradle.kts
+++ b/features/linknewdevice/impl/build.gradle.kts
@@ -43,7 +43,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
- implementation(projects.libraries.oidc.api)
+ implementation(projects.libraries.oauth.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
@@ -56,7 +56,7 @@ dependencies {
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.libraries.oidc.test)
+ testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
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
index 54baee6663..61645ead9d 100644
--- 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
@@ -26,7 +26,9 @@ 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.enterprise.api.SessionEnterpriseService
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
+import io.element.android.features.linknewdevice.impl.screens.confirmation.CodeConfirmationNode
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
@@ -64,6 +66,7 @@ class LinkNewDeviceFlowNode(
private val sessionCoroutineScope: CoroutineScope,
private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler,
+ private val sessionEnterpriseService: SessionEnterpriseService,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -107,6 +110,11 @@ class LinkNewDeviceFlowNode(
val data: String,
) : NavTarget
+ @Parcelize
+ data class CodeConfirmation(
+ val code: String,
+ ) : NavTarget
+
@Parcelize
data object MobileEnterNumber : NavTarget
@@ -136,8 +144,14 @@ class LinkNewDeviceFlowNode(
navigateToError(linkMobileStep.errorType)
}
is LinkMobileStep.QrReady -> {
- // The QrCode is ready, navigate to its display
- backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
+ // The QrCode is ready, navigate to its display, if not already there
+ val navTarget = backstack.elements.value.last().key.navTarget
+ if (navTarget !is NavTarget.MobileShowQrCode) {
+ backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data))
+ }
+ }
+ LinkMobileStep.QrRotating -> {
+ // This step is handled in ShowQrCodePresenter
}
is LinkMobileStep.QrScanned -> {
backstack.replace(NavTarget.MobileEnterNumber)
@@ -145,10 +159,7 @@ class LinkNewDeviceFlowNode(
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()
- }
+ LinkMobileStep.SyncingSecrets -> Unit
is LinkMobileStep.WaitingForAuth -> {
navigateToBrowser(linkMobileStep.verificationUri)
}
@@ -166,7 +177,9 @@ class LinkNewDeviceFlowNode(
is LinkDesktopStep.Error -> {
navigateToError(linkDesktopStep.errorType)
}
- is LinkDesktopStep.EstablishingSecureChannel -> Unit
+ is LinkDesktopStep.EstablishingSecureChannel -> {
+ backstack.push(NavTarget.CodeConfirmation(linkDesktopStep.checkCodeString))
+ }
is LinkDesktopStep.InvalidQrCode -> {
// This error will be handled by the ScanQrCodeNode
}
@@ -183,20 +196,20 @@ class LinkNewDeviceFlowNode(
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.DeviceNotFound -> ErrorScreenType.UnknownError
- is ErrorType.Unknown -> ErrorScreenType.UnknownError
- is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError
- is ErrorType.Cancelled -> ErrorScreenType.UnknownError
+ is ErrorType.InvalidCheckCode -> ErrorScreenType.Mismatch2Digits
+ is ErrorType.UnsupportedProtocol -> ErrorScreenType.ProtocolNotSupported
+ is ErrorType.Cancelled -> ErrorScreenType.Cancelled
is ErrorType.ConnectionInsecure -> ErrorScreenType.InsecureChannelDetected
- is ErrorType.Expired -> ErrorScreenType.Expired
- is ErrorType.OtherDeviceAlreadySignedIn -> ErrorScreenType.UnknownError
+ is ErrorType.Expired,
+ is ErrorType.NotFound,
+ is ErrorType.DeviceNotFound -> ErrorScreenType.Expired
+ is ErrorType.OtherDeviceAlreadySignedIn -> ErrorScreenType.OtherDeviceAlreadySignedIn
+ // TODO check if we expect to hit this here or if it should be caught earlier on
is ErrorType.UnsupportedQrCodeType -> ErrorScreenType.UnknownError
+ is ErrorType.MissingSecretsBackup,
+ is ErrorType.DeviceIdAlreadyInUse,
+ is ErrorType.Unknown -> ErrorScreenType.UnknownError
}
// It is OK to push on backstack, since when user leaves the error screen, a new root will be set,
// or the whole flow will be popped.
@@ -250,6 +263,18 @@ class LinkNewDeviceFlowNode(
}
createNode(buildContext, listOf(callback))
}
+ is NavTarget.CodeConfirmation -> {
+ val callback = object : CodeConfirmationNode.Callback {
+ override fun onCancel() {
+ // Push error
+ backstack.push(NavTarget.Error(ErrorScreenType.Cancelled))
+ }
+ }
+ val inputs = CodeConfirmationNode.Inputs(
+ code = navTarget.code,
+ )
+ createNode(buildContext, listOf(inputs, callback))
+ }
is NavTarget.MobileShowQrCode -> {
val callback = object : ShowQrCodeNode.Callback {
override fun navigateBack() {
@@ -281,8 +306,12 @@ class LinkNewDeviceFlowNode(
}
}
- private fun navigateToBrowser(url: String) {
- activity?.openUrlInChromeCustomTab(null, darkTheme, url)
+ private suspend fun navigateToBrowser(url: String) {
+ activity?.openUrlInChromeCustomTab(
+ session = null,
+ darkTheme = darkTheme,
+ url = sessionEnterpriseService.tweakMasUrl(url),
+ )
}
@Composable
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
index 157d946eaa..18d67f577a 100644
--- 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
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
@@ -65,4 +66,15 @@ class LinkNewMobileHandler(
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
}
}
+
+ fun rotateQrCode() {
+ createAndStartNewHandler()
+ }
+
+ fun onTooManyRotation() {
+ reset()
+ sessionScope.launch {
+ linkMobileStepFlow.emit(LinkMobileStep.Error(ErrorType.Expired("Too many QR code rotations")))
+ }
+ }
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationNode.kt
new file mode 100644
index 0000000000..a8db4d2d75
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationNode.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.linknewdevice.impl.screens.confirmation
+
+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 CodeConfirmationNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+) : Node(buildContext = buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun onCancel()
+ }
+
+ data class Inputs(
+ val code: String,
+ ) : NodeInputs
+
+ private val callback: Callback = callback()
+ private val input = inputs()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ CodeConfirmationView(
+ code = input.code,
+ onCancel = callback::onCancel,
+ )
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationView.kt
new file mode 100644
index 0000000000..d981574f86
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/confirmation/CodeConfirmationView.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.linknewdevice.impl.screens.confirmation
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+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.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.CircularProgressIndicator
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun CodeConfirmationView(
+ code: String,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BackHandler(onBack = onCancel)
+ FlowStepPage(
+ modifier = modifier,
+ iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()),
+ title = stringResource(R.string.screen_qr_code_login_device_code_title),
+ subTitle = stringResource(R.string.screen_qr_code_login_device_code_subtitle),
+ content = { Content(code = code) },
+ buttons = { Buttons(onCancel = onCancel) }
+ )
+}
+
+@Composable
+private fun Content(code: String) {
+ Column(
+ modifier = Modifier.padding(top = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Digits(code = code)
+ Spacer(modifier = Modifier.height(32.dp))
+ WaitingForOtherDevice()
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun Digits(code: String) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ code.forEach {
+ Text(
+ modifier = Modifier
+ .padding(horizontal = 6.dp, vertical = 4.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(ElementTheme.colors.bgActionSecondaryPressed)
+ .padding(horizontal = 16.dp, vertical = 17.dp),
+ text = it.toString()
+ )
+ }
+ }
+}
+
+@Composable
+private fun WaitingForOtherDevice() {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(20.dp)
+ .padding(2.dp),
+ strokeWidth = 2.dp,
+ )
+ Text(
+ text = stringResource(R.string.screen_qr_code_login_verify_code_loading),
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun Buttons(
+ onCancel: () -> Unit,
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_cancel),
+ onClick = onCancel,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun CodeConfirmationViewPreview() {
+ ElementPreview {
+ CodeConfirmationView(
+ code = "67",
+ onCancel = {},
+ )
+ }
+}
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
index b92a19ef8a..ad8cc276c5 100644
--- 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
@@ -20,6 +20,9 @@ sealed interface ErrorScreenType : NodeInputs, Parcelable {
@Parcelize
data object Expired : ErrorScreenType
+ @Parcelize
+ data object OtherDeviceAlreadySignedIn : ErrorScreenType
+
@Parcelize
data object Mismatch2Digits : 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
index 7fd699101b..5946eb9ab2 100644
--- 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
@@ -19,5 +19,6 @@ class ErrorScreenTypeProvider : PreviewParameterProvider {
ErrorScreenType.InsecureChannelDetected,
ErrorScreenType.SlidingSyncNotAvailable,
ErrorScreenType.UnknownError,
+ ErrorScreenType.OtherDeviceAlreadySignedIn,
)
}
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
index 9f67e8bc17..4db2aa9ad5 100644
--- 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
@@ -47,17 +47,26 @@ fun ErrorView(
) {
val appName = LocalBuildMeta.current.applicationName
BackHandler(onBack = onCancel)
+ val iconStyle = when (errorScreenType) {
+ ErrorScreenType.OtherDeviceAlreadySignedIn -> BigIcon.Style.SuccessSolid
+ else -> BigIcon.Style.AlertSolid
+ }
FlowStepPage(
modifier = modifier,
- iconStyle = BigIcon.Style.AlertSolid,
+ iconStyle = iconStyle,
title = titleText(errorScreenType, appName),
subTitle = subtitleText(errorScreenType, appName),
content = { Content(errorScreenType) },
buttons = {
- Buttons(
- onRetry = onRetry,
- onCancel = onCancel,
- )
+ when (errorScreenType) {
+ ErrorScreenType.OtherDeviceAlreadySignedIn -> DoneButton(
+ onDone = onCancel,
+ )
+ else -> Buttons(
+ onRetry = onRetry,
+ onCancel = onCancel,
+ )
+ }
},
)
}
@@ -72,6 +81,7 @@ private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when
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)
+ ErrorScreenType.OtherDeviceAlreadySignedIn -> stringResource(R.string.screen_qr_code_login_error_device_already_signed_in_title)
}
@Composable
@@ -84,6 +94,7 @@ private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = wh
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)
+ ErrorScreenType.OtherDeviceAlreadySignedIn -> stringResource(R.string.screen_qr_code_login_error_device_already_signed_in_subtitle)
}
@Composable
@@ -124,6 +135,17 @@ private fun Content(errorScreenType: ErrorScreenType) {
}
}
+@Composable
+private fun DoneButton(
+ onDone: () -> Unit,
+) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_done),
+ onClick = onDone,
+ )
+}
+
@Composable
private fun Buttons(
onRetry: () -> Unit,
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
index a884c3e97f..20bd50f488 100644
--- 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
@@ -25,6 +25,7 @@ import io.element.android.libraries.di.SessionScope
class ShowQrCodeNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
+ showQrCodePresenterFactory: ShowQrCodePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
class Inputs(
val data: String,
@@ -36,11 +37,15 @@ class ShowQrCodeNode(
private val inputs: Inputs = inputs()
private val callback: Callback = callback()
+ private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
+ initialData = inputs.data,
+ )
@Composable
override fun View(modifier: Modifier) {
+ val state = showQrCodePresenter.present()
ShowQrCodeView(
- data = inputs.data,
+ state = state,
modifier = modifier,
onBackClick = callback::navigateBack,
)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt
new file mode 100644
index 0000000000..21071a6831
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt
@@ -0,0 +1,87 @@
+/*
+ * 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.linknewdevice.impl.screens.qrcode
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+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.AsyncData
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.log.logger.LoggerTag
+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.delay
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
+
+@AssistedInject
+class ShowQrCodePresenter(
+ @Assisted private val initialData: String,
+ private val linkNewMobileHandler: LinkNewMobileHandler,
+) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(initialData: String): ShowQrCodePresenter
+ }
+
+ private var loadingJob: Job? = null
+
+ @Composable
+ override fun present(): ShowQrCodeState {
+ var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
+ val state by produceState(
+ initialValue = ShowQrCodeState(
+ data = AsyncData.Success(initialData),
+ )
+ ) {
+ linkNewMobileHandler.stepFlow.collect { step ->
+ when (step) {
+ is LinkMobileStep.QrReady -> {
+ loadingJob?.cancel()
+ value = ShowQrCodeState(
+ data = AsyncData.Success(step.data),
+ )
+ }
+ is LinkMobileStep.QrRotating -> {
+ if (qrCodeRotationCounter-- > 0) {
+ Timber.tag(tag.value).d("Rotating QrCode")
+ linkNewMobileHandler.rotateQrCode()
+ // Ensure that outdated data is not rendered too long while rotating QR code
+ loadingJob = launch {
+ delay(1000)
+ value = ShowQrCodeState(
+ data = AsyncData.Loading(),
+ )
+ }
+ } else {
+ Timber.tag(tag.value).w("Max QR code rotation reached, not rotating anymore")
+ linkNewMobileHandler.onTooManyRotation()
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ return state
+ }
+
+ companion object {
+ const val MAX_QR_CODE_ROTATION = 10
+ }
+}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt
new file mode 100644
index 0000000000..e69dde8264
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeState.kt
@@ -0,0 +1,14 @@
+/*
+ * 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.linknewdevice.impl.screens.qrcode
+
+import io.element.android.libraries.architecture.AsyncData
+
+data class ShowQrCodeState(
+ val data: AsyncData,
+)
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt
new file mode 100644
index 0000000000..e6d33c2544
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeStateProvider.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.linknewdevice.impl.screens.qrcode
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.AsyncData
+
+class ShowQrCodeStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aShowQrCodeState(),
+ aShowQrCodeState(
+ data = AsyncData.Loading(),
+ ),
+ )
+}
+
+internal fun aShowQrCodeState(
+ data: AsyncData = AsyncData.Success("DATA"),
+) = ShowQrCodeState(
+ data = data,
+)
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
index 501415f621..f2cd07f4a5 100644
--- 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
@@ -9,6 +9,12 @@
package io.element.android.features.linknewdevice.impl.screens.qrcode
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,6 +27,7 @@ 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.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
@@ -30,6 +37,7 @@ 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.utils.annotatedTextWithBold
import io.element.android.libraries.qrcode.QrCodeImage
import kotlinx.collections.immutable.persistentListOf
@@ -38,9 +46,10 @@ import kotlinx.collections.immutable.persistentListOf
* QrCode display screen:
* https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617
*/
+@OptIn(ExperimentalAnimationApi::class)
@Composable
fun ShowQrCodeView(
- data: String,
+ state: ShowQrCodeState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -55,11 +64,17 @@ fun ShowQrCodeView(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- QrCodeImage(
- data = data,
- modifier = Modifier
- .size(220.dp)
- )
+ AnimatedContent(
+ modifier = Modifier.size(220.dp),
+ targetState = state.data.dataOrNull(),
+ transitionSpec = {
+ fadeIn().togetherWith(fadeOut())
+ }
+ ) { data ->
+ QrCodeOrLoading(
+ data = data,
+ )
+ }
Spacer(modifier = Modifier.height(32.dp))
NumberedListOrganism(
modifier = Modifier.fillMaxSize(),
@@ -79,11 +94,33 @@ fun ShowQrCodeView(
}
}
+@Composable
+private fun QrCodeOrLoading(
+ data: String?,
+ modifier: Modifier = Modifier,
+) {
+ if (data == null) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ QrCodeImage(
+ modifier = modifier,
+ data = data,
+ )
+ }
+}
+
@PreviewsDayNight
@Composable
-internal fun ShowQrCodeViewPreview() = ElementPreview {
+internal fun ShowQrCodeViewPreview(
+ @PreviewParameter(ShowQrCodeStateProvider::class) state: ShowQrCodeState,
+) = ElementPreview {
ShowQrCodeView(
- data = "DATA",
+ 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
index 16372fa6e4..378a405c95 100644
--- a/features/linknewdevice/impl/src/main/res/values-be/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-be/translations.xml
@@ -17,6 +17,8 @@
"Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi.""Калі гэта не дапамагло, увайдзіце ўручную""Злучэнне небяспечнае"
+ "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе."
+ "Увядзіце наступны нумар на іншай прыладзе.""Уваход быў адменены на іншай прыладзе.""Запыт на ўваход скасаваны""Уваход на іншай прыладзе быў адхілены."
@@ -35,4 +37,5 @@
"Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады.""Дазвольце доступ да камеры для сканіравання QR-кода""Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз."
+ "У чаканні іншай прылады"
diff --git a/features/linknewdevice/impl/src/main/res/values-ca/translations.xml b/features/linknewdevice/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..d78744ff85
--- /dev/null
+++ b/features/linknewdevice/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,41 @@
+
+
+ "Escaneja el QR"
+ "Escaneja el codi QR amb aquest dispositiu"
+ "Preparat per escanejar"
+ "El proveïdor del teu compte no admet %1$s."
+ "%1$s no és compatible"
+ "Codi QR no compatible"
+ "L\'inici de sessió s\'ha cancel·lat a l\'altre dispositiu."
+ "Sol·licitud d\'inici de sessió cancel·lada"
+ "Inici de sessió ha caducat. Torna-ho a provar."
+ "L\'inici de sessió no s\'ha completat a temps"
+ "Selecciona %1$s"
+ "No s\'ha pogut establir una connexió segura amb el dispositiu nou. Els dispositius existents continuen sent segurs, no te n\'has de preocupar."
+ "I ara què?"
+ "Prova de tornar a iniciar sessió mitjançant un codi QR si es tracta d\'un problema de xarxa."
+ "Si es repeteix el mateix problema, prova una xarxa wifi diferent o utilitza les dades mòbils en lloc del wifi."
+ "Si no funciona, inicia sessió manualment"
+ "Connexió no segura"
+ "Se\'t demanarà que introdueixis els dos dígits mostrats en aquest dispositiu."
+ "Introdueix el número següent a l\'altre dispositiu"
+ "L\'inici de sessió s\'ha cancel·lat a l\'altre dispositiu."
+ "Sol·licitud d\'inici de sessió cancel·lada"
+ "L\'inici de sessió s\'ha rebutjat a l\'altre dispositiu."
+ "Inici de sessió rebutjat"
+ "Inici de sessió ha caducat. Torna-ho a provar."
+ "L\'inici de sessió no s\'ha completat a temps"
+ "El teu altre dispositiu no admet l\'inici de sessió a %s amb codis QR.
+
+Prova d\'iniciar la sessió manualment o escaneja el QR amb un altre dispositiu."
+ "Codi QR no compatible"
+ "El proveïdor del teu compte no admet %1$s."
+ "%1$s no és compatible"
+ "Utilitza el codi QR que es mostra a l\'altre dispositiu."
+ "Torna-ho a intentar"
+ "Codi QR incorrecte"
+ "Per continuar, has donar permís a %1$s per poder utilitzar la càmera del dispositiu."
+ "Permet l\'accés a la càmera per poder escanejar el codi QR"
+ "S\'ha produït un error inesperat. Torna-ho a provar."
+ "Esperant el teu altre dispositiu"
+
diff --git a/features/linknewdevice/impl/src/main/res/values-cs/translations.xml b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
index 4b8f230d55..e0150668a3 100644
--- a/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml
@@ -34,6 +34,8 @@
"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é"
+ "Budete požádáni o zadání dvou níže uvedených číslic."
+ "Zadejte níže uvedené číslo na svém dalším zařízení""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."
@@ -54,4 +56,5 @@ Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízen
"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."
+ "Čekání na vaše další zařízení"
diff --git a/features/linknewdevice/impl/src/main/res/values-cy/translations.xml b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
index b26aed52ef..6b1cb7781f 100644
--- a/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml
@@ -17,6 +17,8 @@
"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"
+ "Bydd gofyn i chi nodi\'r ddau ddigid sy\'n cael eu dangos ar y ddyfais hon."
+ "Rhowch y rhif isod ar eich dyfais arall""Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall.""Cais mewngofnodi wedi\'i ddiddymu""Cafodd y mewngofnodi ar y ddyfais arall ei wrthod."
@@ -35,4 +37,5 @@ Ceisiwch fewngofnodi â llaw, neu sganiwch y cod QR gyda dyfais arall.""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."
+ "Yn aros am eich dyfais arall"
diff --git a/features/linknewdevice/impl/src/main/res/values-da/translations.xml b/features/linknewdevice/impl/src/main/res/values-da/translations.xml
index 5bc9f04fb9..45f510e90b 100644
--- a/features/linknewdevice/impl/src/main/res/values-da/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-da/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Du bliver bedt om at indtaste de to cifre, der vises på denne enhed."
+ "Indtast nummeret herunder på din anden enhed""Login blev annulleret på den anden enhed.""Anmodning om login annulleret""Login blev afvist på den anden enhed."
@@ -54,4 +56,5 @@ Prøv at logge ind manuelt, eller scan QR-koden med en anden enhed.""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."
+ "Venter på din anden enhed"
diff --git a/features/linknewdevice/impl/src/main/res/values-de/translations.xml b/features/linknewdevice/impl/src/main/res/values-de/translations.xml
index b8ad8b80ef..773878c736 100644
--- a/features/linknewdevice/impl/src/main/res/values-de/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-de/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."
+ "Trage die unten angezeigte Zahl auf einem anderen Device ein""Die Anmeldung wurde auf dem anderen Gerät abgebrochen.""Anmeldeanfrage abgebrochen""Die Anmeldung auf dem anderen Gerät wurde abgelehnt."
@@ -54,4 +56,5 @@ Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Ger
"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."
+ "Warten auf dein anderes Gerät"
diff --git a/features/linknewdevice/impl/src/main/res/values-el/translations.xml b/features/linknewdevice/impl/src/main/res/values-el/translations.xml
index 26c917075b..6c0e77da40 100644
--- a/features/linknewdevice/impl/src/main/res/values-el/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-el/translations.xml
@@ -34,6 +34,8 @@
"Εάν αντιμετωπίσεις το ίδιο πρόβλημα, δοκίμασε ένα διαφορετικό δίκτυο wifi ή χρησιμοποίησε τα δεδομένα του κινητού σου αντί για wifi""Εάν δεν λειτουργήσει, συνδέσου χειροκίνητα""Η σύνδεση δεν είναι ασφαλής"
+ "Θα σου ζητηθεί να εισάγεις τα δύο ψηφία που εμφανίζονται σε αυτήν τη συσκευή."
+ "Εισήγαγε τον παρακάτω αριθμό στην άλλη συσκευή σου""Η σύνδεση ακυρώθηκε στην άλλη συσκευή.""Το αίτημα σύνδεσης ακυρώθηκε""Η σύνδεση απορρίφθηκε στην άλλη συσκευή."
@@ -54,4 +56,5 @@
"Πρέπει να δώσεις άδεια για %1$s για να χρησιμοποιήσεις την κάμερα της συσκευής σου και να συνεχίσεις.""Επέτρεψε την πρόσβαση της κάμερας για σάρωση του κωδικού QR""Παρουσιάστηκε ένα απροσδόκητο σφάλμα. Παρακαλώ προσπάθησε ξανά."
+ "Αναμονή για την άλλη σου συσκευή"
diff --git a/features/linknewdevice/impl/src/main/res/values-es/translations.xml b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
index 032813a2c4..c33dc3ad88 100644
--- a/features/linknewdevice/impl/src/main/res/values-es/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-es/translations.xml
@@ -17,6 +17,8 @@
"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"
+ "Se te pedirá que introduzcas los dos dígitos mostrados en este dispositivo."
+ "Introduce el número que aparece a continuación en tu otro dispositivo""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."
@@ -35,4 +37,5 @@ Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo
"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."
+ "A la espera de tu otro dispositivo"
diff --git a/features/linknewdevice/impl/src/main/res/values-et/translations.xml b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
index 6aa1398e0a..10f8af5e11 100644
--- a/features/linknewdevice/impl/src/main/res/values-et/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-et/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Sul palutakse sisestada kaks selles seadmes kuvatud numbrit."
+ "Sisesta see number oma teises seadmes""Sisselogimine katkestati teises seadmes.""Sisselogimispäring on tühistatud""Sisselogimisest on teises seadmes keeldutud."
@@ -54,4 +56,5 @@ Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega.""Jätkamiseks pead lubama, et %1$s saab kasutada sinu nutiseadme kaamerat""QR-koodi lugemiseks luba kaamerat kasutada""Tekkis ootamatu viga. Palun proovi uuesti."
+ "Ootame sinu teise seadme järgi"
diff --git a/features/linknewdevice/impl/src/main/res/values-eu/translations.xml b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
index 06cc0fd857..8680ad94c7 100644
--- a/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml
@@ -16,6 +16,8 @@
"Saiatu berriro QR kodearekin saioa hasten sare-arazo bat izan bada""Horrek ez badu funtzionatzen, hasi saioa eskuz""Konexioa ez da segurua"
+ "Gailu honetan agertzen diren bi digituak sartzeko eskatuko zaizu."
+ "Sartu beheko zenbakia beste gailuan""Saioa hasteko eskaera bertan behera utzi da beste gailuan""Saioa hasteko eskaera bertan behera utzi da""Saioa hasteari uko egin zaio beste dispositiboan."
@@ -33,4 +35,5 @@ Saiatu saioa eskuz hasten, edo eskaneatu QR kodea beste gailu batean.""QR kode okerra""Baimendu kameraren sarbidea QR kodea eskaneatzeko""Ustekabeko errore bat gertatu da. Saiatu berriro."
+ "Beste gailuaren zain"
diff --git a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
index 804fa653ad..07c329ef6a 100644
--- a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml
@@ -15,6 +15,8 @@
"اکنون چه؟""ورود دستی در صورت کار نکردنش""اتّصال ناامن"
+ "از شما خواسته خواهد شد که دو رقم نشان داده روی این افزاره را وارد کنید."
+ "شمارهٔ زیر را روی افزارهٔ دیگرتان وارد کنید""ورود روی افزارهٔ دیگر لغو شد.""درخواست ورد لغو شد""ورود به دست افزارهٔ دیگر رد شد."
@@ -33,4 +35,5 @@
"برای ادامه باید اجازهٔ استفادهٔ %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
index f8e999f886..0ba5a30e58 100644
--- a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Sinua pyydetään antamaan tässä laitteessa näkyvät kaksi numeroa."
+ "Kirjoita alla oleva numero toisella laitteellasi""Kirjautuminen peruutettiin toisella laitteella.""Kirjautumispyyntö peruutettu""Kirjautuminen hylättiin toisella laitteella."
@@ -54,4 +56,5 @@ Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella
"Jatkaaksesi sinun on annettava lupa %1$s -sovellukselle käyttää laitteesi kameraa.""Salli lupa kameraan QR-koodin skannaamiseksi""Tapahtui odottamaton virhe. Yritä uudelleen."
+ "Odotetaan toista laitettasi"
diff --git a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
index 0c91dca7a1..12a770af17 100644
--- a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil."
+ "Saisissez le nombre ci-dessous sur votre autre appareil""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."
@@ -52,4 +54,5 @@
"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."
+ "En attente de votre autre session"
diff --git a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
index 20c194ef93..8561e63ad3 100644
--- a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml
@@ -34,6 +34,8 @@
"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""Prijava je otkazana na drugom uređaju.""Zahtjev za prijavu je otkazan""Prijava je odbijena na drugom uređaju."
@@ -54,4 +56,5 @@ Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem.""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."
+ "Čekanje na vaš drugi uređaj"
diff --git a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
index 51fe30bbd8..8cefed94cc 100644
--- a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml
@@ -34,6 +34,8 @@
"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 rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."
+ "Adja meg az alábbi számot a másik eszközén""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."
@@ -54,4 +56,5 @@ Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik e
"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."
+ "Várakozás a másik eszközre"
diff --git a/features/linknewdevice/impl/src/main/res/values-in/translations.xml b/features/linknewdevice/impl/src/main/res/values-in/translations.xml
index 20badba9ba..5508993090 100644
--- a/features/linknewdevice/impl/src/main/res/values-in/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-in/translations.xml
@@ -17,6 +17,8 @@
"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"
+ "Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di perangkat ini."
+ "Masukkan nomor bawah di perangkat Anda yang lain""Proses masuk dibatalkan di perangkat lain.""Permintaan masuk dibatalkan""Proses masuk ditolak di perangkat lain."
@@ -35,4 +37,5 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain."
"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."
+ "Menunggu perangkat Anda yang lain"
diff --git a/features/linknewdevice/impl/src/main/res/values-it/translations.xml b/features/linknewdevice/impl/src/main/res/values-it/translations.xml
index 9a6476823a..6a32c70fe4 100644
--- a/features/linknewdevice/impl/src/main/res/values-it/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-it/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Ti verrà chiesto di inserire le due cifre mostrate su questo dispositivo."
+ "Inserisci il numero qui sotto sull\'altro dispositivo""L\'accesso è stato annullato sull\'altro dispositivo.""Richiesta di accesso annullata""L\'accesso è stato rifiutato sull\'altro dispositivo."
@@ -54,4 +56,5 @@ Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo.
"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."
+ "In attesa dell\'altro dispositivo"
diff --git a/features/linknewdevice/impl/src/main/res/values-ja/translations.xml b/features/linknewdevice/impl/src/main/res/values-ja/translations.xml
index 6cfd5baf84..c4f09dcd0e 100644
--- a/features/linknewdevice/impl/src/main/res/values-ja/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ja/translations.xml
@@ -18,7 +18,7 @@
"サインインが無効です。もう一度試してください。""サインインが時間内に完了しませんでした""%1$s を他の端末で開いてください"
- "%1$s を選択してください"
+ "%1$s を選択""\"QRコードでサインイン\"""表示されているQRコードを一方の端末で読み取ってください""%1$s を他の端末で開いてください"
@@ -34,6 +34,8 @@
"同様の問題が発生する場合は、異なるWi-Fiやモバイルデータ通信を試してください""問題が解決しない場合は、手動でサインインしてください""接続が安全ではありません"
+ "この端末に表示される2つの数字の入力を要求されます"
+ "もう一方に表示される数字を入力してください""もう一方の端末がサインインをキャンセルしました""サインインのリクエストがキャンセルされました""もう一方の端末でサインインを拒否されました"
@@ -54,4 +56,5 @@
"続行するには、%1$s にカメラの使用を許可する必要があります。""QRコードを読み取るため、カメラへのアクセスを許可""予期せぬ問題が発生しました。もう一度試してください。"
+ "一方の端末を待機しています"
diff --git a/features/linknewdevice/impl/src/main/res/values-ko/translations.xml b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
index 3b31c8fdc2..1d69f58ca5 100644
--- a/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml
@@ -34,6 +34,8 @@
"동일한 문제를 겪으신 경우 다른 Wi-Fi 네트워크를 사용해 보거나 Wi-Fi 대신 모바일 데이터를 사용해 보세요.""만약 작동하지 않는 경우, 수동으로 로그인하세요.""연결이 안전하지 않습니다"
+ "이 장치에 표시된 두 자리 숫자를 입력하라는 메시지가 표시됩니다."
+ "다른 device 에 아래 번호를 입력하세요""다른 기기에서 로그인이 취소되었습니다.""로그인 요청이 취소되었습니다""다른 기기에서 로그인이 거부되었습니다."
@@ -54,4 +56,5 @@
"계속하려면 %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
index 6b8541b25b..4e8844ee2f 100644
--- a/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Du blir bedt om å skrive inn de to sifrene som vises på denne enheten."
+ "Skriv inn nummeret nedenfor på den andre enheten""Påloggingen ble kansellert på den andre enheten.""Påloggingsforespørsel kansellert""Påloggingen ble avvist på den andre enheten."
@@ -54,4 +56,5 @@ Prøv å logge på manuelt, eller skann QR-koden med en annen enhet."
"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."
+ "Venter på den andre enheten din"
diff --git a/features/linknewdevice/impl/src/main/res/values-nl/translations.xml b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
index 407a470e48..6dbc2c18c2 100644
--- a/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml
@@ -17,6 +17,8 @@
"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"
+ "Daar word je gevraagd om de twee cijfers in te voeren die op dit apparaat worden weergegeven."
+ "Voer het onderstaande nummer in op je andere apparaat""De aanmelding is geannuleerd op het andere apparaat.""Login verzoek geannuleerd""De aanmelding is geweigerd op het andere apparaat."
@@ -35,4 +37,5 @@ Probeer handmatig in te loggen, of scan de QR code met een ander apparaat.""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."
+ "Aan het wachten op je andere apparaat"
diff --git a/features/linknewdevice/impl/src/main/res/values-pl/translations.xml b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
index 4db42a2a49..ced8955d1d 100644
--- a/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml
@@ -1,26 +1,47 @@
"Skanuj kod QR"
+ "Otwórz %1$s na laptopie lub komputerze stacjonarnym""Zeskanuj kod QR za pomocą tego urządzenia""Gotowy do skanowania"
+ "Otwórz %1$s na komputerze stacjonarnym, aby uzyskać kod QR"
+ "Liczby nie pasują do siebie"
+ "Wprowadź 2-cyfrowy kod"
+ "Pozwoli to sprawdzić, czy połączenie z drugim urządzeniem jest bezpieczne."
+ "Wprowadź numer wyświetlany na drugim urządzeniu""Twój dostawca konta nie obsługuje %1$s.""%1$s nie jest wspierany"
+ "Twój dostawca konta nie wspiera logowania na nowym urządzeniu za pomocą kodu QR.""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"
+ "Otwórz %1$s na drugim urządzeniu""Wybierz %1$s"
+ "“Zaloguj się za pomocą kodu QR”"
+ "Zeskanuj kod QR pokazany tutaj za pomocą drugiego urządzenia"
+ "Otwórz %1$s na drugim urządzeniu"
+ "Komputer stacjonarny"
+ "Ładowanie kodu QR…"
+ "Urządzenie mobilne"
+ "Jakiego typu urządzenie chcesz powiązać?"
+ "Spróbuj ponownie i upewnij się, że 2-cyfrowy kod został wpisany prawidłowo. Jeśli liczby wciąż się nie zgadzają, skontaktuj się ze swoim dostawcą konta."
+ "Liczby nie pasują do siebie""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"
+ "Zostaniesz poproszony o wprowadzenie dwóch cyfr widocznych na tym urządzeniu."
+ "Wprowadź numer poniżej na innym urządzeniu""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"
+ "Nie musisz już robić nic więcej."
+ "Twoje drugie urządzenie jest już zalogowane""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.
@@ -35,4 +56,5 @@ Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu.""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."
+ "Oczekiwanie na drugie urządzenie"
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
index f11bdc6e6d..1680e5c0ff 100644
--- a/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml
@@ -34,6 +34,8 @@
"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"
+ "Você será solicitado a inserir os dois dígitos mostrados neste dispositivo."
+ "Digite o número abaixo no seu outro dispositivo""A entrada foi cancelada no outro dispositivo.""Solicitação de entrada foi cancelada""A entrada foi recusada no outro dispositivo."
@@ -54,4 +56,5 @@ Tente entrar manualmente ou ler o código QR com outro dispositivo."
"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."
+ "Aguardando seu outro dispositivo"
diff --git a/features/linknewdevice/impl/src/main/res/values-pt/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
index da6da08f38..eff27b17f0 100644
--- a/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml
@@ -17,6 +17,8 @@
"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"
+ "Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo."
+ "Insere o número abaixo no teu dispositivo""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."
@@ -35,4 +37,5 @@ Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro disposi
"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."
+ "À espera do teu outro dispositivo"
diff --git a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
index f1a4f3db59..57d439240d 100644
--- a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml
@@ -26,6 +26,7 @@
"Se încarcă codul QR…""Dispozitiv mobil""Ce tip de dispozitiv doriți să conectați?"
+ "Încercați din nou și asigurați-vă că ați introdus corect codul de 2 cifre. Dacă numerele tot nu se potrivesc, contactați furnizorul contului.""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?"
@@ -33,10 +34,14 @@
"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ă"
+ "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv."
+ "Introduceți numărul de mai jos pe celălalt dispozitiv""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ă"
+ "Nu trebuie să faceți nimic altceva."
+ "Celălalt dispozitiv este deja conectat""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.
@@ -51,4 +56,5 @@
"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."
+ "În așteptarea celuilalt dispozitiv"
diff --git a/features/linknewdevice/impl/src/main/res/values-ru/translations.xml b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
index 39506417b6..6a8b645c4f 100644
--- a/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
@@ -34,6 +34,8 @@
"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные""Если это не помогло, войдите вручную""Соединение не защищено"
+ "Вам нужно будет ввести две цифры, показанные на этом устройстве."
+ "Введите показанный номер на своем другом устройстве""Вход на другом устройстве был отменен.""Запрос на вход отменен""Вход в систему был отклонен на другом устройстве."
@@ -54,4 +56,5 @@
"Чтобы продолжить, вам необходимо разрешить %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
index cb430671bb..f64c01328b 100644
--- a/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
@@ -34,6 +34,8 @@
"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é"
+ "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení."
+ "Zadajte nižšie uvedené číslo na vašom druhom zariadení""Prihlásenie bolo zrušené na druhom zariadení.""Žiadosť o prihlásenie bola zrušená""Prihlásenie bolo zamietnuté na druhom zariadení."
@@ -54,4 +56,5 @@ Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariade
"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."
+ "Čaká sa na vaše druhé zariadenie"
diff --git a/features/linknewdevice/impl/src/main/res/values-sv/translations.xml b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
index 8a1bef434a..8800b31bbd 100644
--- a/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml
@@ -17,6 +17,8 @@
"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"
+ "Du kommer att bli ombedd att ange de två siffrorna som visas på den här enheten."
+ "Ange numret nedan på din andra enhet""Inloggningen avbröts på den andra enheten.""Inloggningsförfrågan avbröts""Inloggningen avvisades på den andra enheten."
@@ -35,4 +37,5 @@ Prova att logga in manuellt eller skanna QR-koden med en annan enhet.""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."
+ "Väntar på din andra enhet"
diff --git a/features/linknewdevice/impl/src/main/res/values-tr/translations.xml b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
index e5fd989c0b..3c8767f64c 100644
--- a/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml
@@ -33,6 +33,8 @@
"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"
+ "Bu cihazda gösterilen iki haneyi girmeniz istenecektir."
+ "Aşağıdaki numarayı diğer cihazınıza girin""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."
@@ -51,4 +53,5 @@ Manuel olarak oturum açmayı deneyin veya QR kodunu başka bir cihazla tarayın
"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."
+ "Diğer cihazınız bekleniyor"
diff --git a/features/linknewdevice/impl/src/main/res/values-uk/translations.xml b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
index 875b5aed16..752b5ead3f 100644
--- a/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml
@@ -26,6 +26,7 @@
"Завантаження QR-коду…""Мобільний пристрій""Який тип пристрою ви хочете під\'єднати?"
+ "Спробуйте ще раз і переконайтеся, що ви правильно ввели двозначний код. Якщо цифри все одно не збігаються, зверніться до свого провайдера облікового запису.""Цифри не збігаються""Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися.""Що тепер?"
@@ -33,6 +34,8 @@
"Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi""Якщо це не спрацює, увійдіть вручну""З\'єднання не безпечне"
+ "Вас попросять ввести дві цифри, показані на цьому пристрої."
+ "Введіть номер нижче на іншому пристрої""Вхід було скасовано на іншому пристрої.""Запит на вхід скасовано""Вхід був відхилений на іншому пристрої."
@@ -53,4 +56,5 @@
"Вам потрібно дати дозвіл %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
index 54d2c2e401..0b4bb02226 100644
--- a/features/linknewdevice/impl/src/main/res/values-ur/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml
@@ -17,6 +17,8 @@
"اگر آپ کو بھی یہی مسئلہ درپیش ہو، تو کوئی دوسرا وائی فائی شبکہ آزمائیں یا وائی فائی کے بجائے اپنے محمول بیانات استعمال کریں۔""اگر یہ کام نہ کرے، تو دستی طور پر داخل ہوں""اتصال محفوظ نہیں"
+ "آپ سے اس آلے پر دکھائے گئے دو ہندسوں کو درج کرنے کو کہا جائے گا۔"
+ "اپنے دوسرے آلے پر درج ذیل نمبر درج کریں""دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔""دخول کی درخواست منسوخ""دوسرے آلہ پر دخول کو مسترد کر دیا گیا تھا۔"
@@ -35,4 +37,5 @@
"جاری رکھنے کے لیے آپ %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
index b1f3deebf4..1d436b5f4a 100644
--- a/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
@@ -26,6 +26,7 @@
"QR kod yuklanmoqda…""Mobil qurilma""Qaysi turdagi qurilmani bog‘lashni xohlaysiz?"
+ "Qayta urining va 2 xonali kodni bexato kiritganingizni tekshiring. Agar raqamlar hali ham mos kelmasa, hisobingiz provayderiga murojaat qiling.""Raqamlar mos kelmaydi""Yangi qurilmaga xavfsiz ulanish amalga oshirilmadi. Mavjud qurilmalaringiz hali ham xavfsiz va ular haqida qaygʻurishingiz shart emas.""Endi nima?"
@@ -33,10 +34,14 @@
"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"
+ "Sizdan ushbu qurilmada koʻrsatilgan ikkita raqamni kiritish soʻraladi."
+ "Narigi qurilmada quyidagi raqamni kiriting""Boshqa qurilmadan hisobga kirish bekor qilindi.""Tizimga kirish soʻrovi bekor qilindi""Boshqa qurilmadan hisobga kirish bekor qilindi.""Tizimga kirish rad etildi"
+ "Boshqa hech narsa qilishingiz shart emas."
+ "Boshqa qurilmangiz allaqachon tizimga kirgan""Kirish muddati tugagan. Iltimos, qayta urinib koʻring.""Kirish oʻz vaqtida tugallanmagan""Boshqa qurilmangiz %s hisobiga QR kod orqali kirishni qoʻllab-quvvatlamaydi.
@@ -51,4 +56,5 @@ Oddiy usulda kiring yoki boshqa qurilma bilan QR kodni skanerlang.""Davom etish uchun %1$s qurilmangiz kamerasidan foydalanishiga ruxsat berishingiz kerak.""QR kodni skanerlash uchun kameraga ruxsat bering""Kutilmagan xatolik yuz berdi. Qayta urining."
+ "Boshqa qurilmangiz kutilmoqda"
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
index 9b3cd5ead5..e18fbc13b6 100644
--- a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,26 +1,47 @@
"掃描 QR code"
+ "在筆記型電腦或桌上型電腦上開啟 %1$s""使用此裝置掃描 QR code""準備掃描"
+ "在桌上型電腦上開啟 %1$s 以取得 QR code"
+ "數字不符"
+ "輸入兩位數代碼"
+ "這將確認您與另一台裝置之間的連線是否安全。"
+ "輸入顯示在您的其他裝置上的數字""您的帳號提供者不支援 %1$s。""不支援 %1$s"
+ "您的帳號提供者不支援使用 QR code 登入新裝置。""不支援 QR code""已在其他裝置上取消登入。""已取消登入請求""登入已過期。請再試一次。""未及時完成登入"
+ "在其他裝置上開啟 %1$s""選取 %1$s"
+ "「使用 QR code 登入」"
+ "使用其他裝置掃描此處顯示的 QR code"
+ "在其他裝置上開啟 %1$s"
+ "桌上型電腦"
+ "正在載入 QR code……"
+ "行動裝置"
+ "您想連結哪種類型的裝置?"
+ "請重試,並確定您已輸入兩位數代碼。若數字仍然不符,請聯絡您的帳號提供者。"
+ "數字不符""無法與新裝置建立安全連線。您現有的裝置仍然安全,您不必擔心它們。""現在怎麼辦?""嘗試再次使用 QR code 登入以確認不是網路問題""如果遇到相同的問題,請嘗試使用其他 wifi 網路或您的行動數據""若無法運作,請手動登入""連線不安全"
+ "系統會要求您輸入此裝置上顯示的兩位數字。"
+ "在您的其他裝置上輸入以下數字""已在其他裝置上取消登入。""已取消登入請求""其他裝置拒絕登入。""已拒絕登入"
+ "您不需要進行其他操作。"
+ "您的其他裝置已登入""登入已過期。請再試一次。""未及時完成登入""您的其他裝置不支援使用 QR cpde 登入 %s。
@@ -35,4 +56,5 @@
"您必須授予 %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
index 10ae1ae7a7..f11f60dd61 100644
--- a/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
@@ -1,18 +1,18 @@
"扫描二维码"
- "在笔记本电脑或台式机上打开%1$s "
+ "在笔记本电脑或台式机上打开 %1$s""使用此设备扫描二维码""准备进行扫描"
- "在电脑上打开%1$s 获取二维码"
+ "在台式电脑上打开 %1$s 以获取二维码""数字不匹配"
- "输入两位数的验证码"
- "这将验证您与其他设备的连接是否安全。"
+ "输入两位数字的代码"
+ "这将验证你与其它设备的连接是否安全。""请输入另一台设备上显示的数字"
- "账户提供方不支持 %1$s."
+ "账户提供者不支持 %1$s.""不支持 %1$s."
- "您的账户提供商不支持使用二维码登录新设备。"
- "不支持二维码"
+ "你的账户提供者不支持使用二维码登录到新设备。"
+ "二维码不受支持""登录被另一台设备取消""登录请求已取消""登录已过期. 请重试."
@@ -25,33 +25,36 @@
"台式计算机""正在加载二维码…""移动设备"
- "您想连接哪种类型的设备?"
- "请重试,并确保您已正确输入两位验证码。如果验证码仍然不匹配,请联系您的账户提供商。"
+ "你想连接哪种类型的设备?"
+ "请重试,并确保已正确输入两位数字的代码。如果数字仍然不匹配,请联系账户提供者。""数字不匹配"
- "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。"
+ "无法与新设备建立安全连接。你的现有设备仍然安全,无需担心。""现在怎么办?""如果这是网络问题,请尝试使用二维码再次登录""如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi""如果不起作用,请手动登录""连接不安全"
+ "你将被要求输入此设备上显示的两位数字。"
+ "在你的其它设备上输入以下数字""登录被另一台设备取消""登录请求已取消"
- "其它设备未接受请求"
+ "另一设备上的登录请求已被拒绝。""登录被拒绝"
- "您无需额外操作。"
- "您已在另一台设备登录。"
+ "无需额外操作。"
+ "你已在另一设备上登录。""登录已过期. 请重试.""登录未及时完成""另一个设备不支持使用二维码登录 %s.
尝试手动或使用另一个设备扫描二维码."
- "不支持二维码"
- "账户提供方不支持 %1$s."
+ "二维码不受支持"
+ "账户提供者不支持 %1$s.""不支持 %1$s."
- "使用其他设备上显示的二维码。"
- "再试一次"
+ "使用其它设备上显示的二维码。"
+ "重试""二维码错误"
- "您需要授予 %1$s 使用设备摄像头的权限才能继续。"
- "允许摄像头权限以扫描 QR 码"
+ "你需要授予 %1$s 使用设备摄像头的权限才能继续。"
+ "允许访问摄像头以扫描二维码""发生了意外错误。请再试一次。"
+ "正在等待其它设备"
diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml
index 321b168751..6ffcce227a 100644
--- a/features/linknewdevice/impl/src/main/res/values/localazy.xml
+++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml
@@ -34,6 +34,8 @@
"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"
+ "You’ll be asked to enter the two digits shown on this device."
+ "Enter the number below on your other device""The sign in was cancelled on the other device.""Sign in request cancelled""The sign in was declined on the other device."
@@ -54,4 +56,5 @@ Try signing in manually, or scan the QR code with another device."
"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."
+ "Waiting for your other device"
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
index 2957a89495..1b2af8f4c3 100644
--- 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
@@ -11,6 +11,7 @@ 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.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
@@ -37,6 +38,7 @@ class DefaultLinkNewDeviceEntryPointTest {
sessionCoroutineScope = backgroundScope,
linkNewMobileHandler = LinkNewMobileHandler(client),
linkNewDesktopHandler = LinkNewDesktopHandler(client),
+ sessionEnterpriseService = FakeSessionEnterpriseService(),
)
}
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
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
index ac0a129f49..7609acf809 100644
--- 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
@@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
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.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.linknewdevice.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -18,42 +21,37 @@ 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`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
state = aDesktopNoticeState(),
onBackClicked = callback,
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on back button clicked - calls the expected callback`() {
+ fun `on back button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
state = aDesktopNoticeState(),
onBackClicked = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `when can continue - calls the expected callback`() {
+ fun `when can continue - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
state = aDesktopNoticeState(canContinue = true),
onReadyToScanClick = callback,
)
@@ -61,16 +59,16 @@ class DesktopNoticeViewTest {
}
@Test
- fun `on submit button clicked - emits the Continue event`() {
+ fun `on submit button clicked - emits the Continue event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder()
- rule.setView(
+ setView(
state = aDesktopNoticeState(eventSink = eventRecorder),
)
- rule.clickOn(R.string.screen_link_new_device_desktop_submit)
+ clickOn(R.string.screen_link_new_device_desktop_submit)
eventRecorder.assertSingle(DesktopNoticeEvent.Continue)
}
- private fun AndroidComposeTestRule.setView(
+ private fun AndroidComposeUiTest.setView(
state: DesktopNoticeState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
onReadyToScanClick: () -> Unit = EnsureNeverCalled(),
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
index aa52a70149..b63d7471ac 100644
--- 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
@@ -5,58 +5,56 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
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.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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.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 onCancel callback`() {
+ fun `on back pressed - calls the onCancel callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setErrorView(
+ setErrorView(
onCancel = callback,
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on try again button clicked - calls the expected callback`() {
+ fun `on try again button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setErrorView(
+ setErrorView(
onRetry = callback
)
- rule.clickOn(CommonStrings.action_try_again)
+ clickOn(CommonStrings.action_try_again)
}
}
@Test
- fun `on cancel button clicked - calls the expected callback`() {
+ fun `on cancel button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setErrorView(
+ setErrorView(
onCancel = callback
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
}
}
- private fun AndroidComposeTestRule.setErrorView(
+ private fun AndroidComposeUiTest.setErrorView(
onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,
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
index 20e1d898dd..25dc9efa8a 100644
--- 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
@@ -5,13 +5,16 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.linknewdevice.impl.screens.number
import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
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.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -20,65 +23,60 @@ 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`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
state = aEnterNumberState(),
onBackClicked = callback,
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on back button clicked - calls the expected callback`() {
+ fun `on back button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
state = aEnterNumberState(),
onBackClicked = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `on continue button clicked - emits the Continue event`() {
+ fun `on continue button clicked - emits the Continue event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder()
- rule.setView(
+ setView(
state = aEnterNumberState(
number = "12",
eventSink = eventRecorder,
),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventRecorder.assertSingle(EnterNumberEvent.Continue)
}
@Test
- fun `when the number is not complete, continue button is disabled`() {
+ fun `when the number is not complete, continue button is disabled`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder(expectEvents = false)
- rule.setView(
+ setView(
state = aEnterNumberState(
number = "1",
eventSink = eventRecorder,
),
)
- val continueStr = rule.activity.getString(CommonStrings.action_continue)
- rule.onNodeWithText(continueStr).assertIsNotEnabled()
+ val continueStr = activity!!.getString(CommonStrings.action_continue)
+ onNodeWithText(continueStr).assertIsNotEnabled()
}
- private fun AndroidComposeTestRule.setView(
+ private fun AndroidComposeUiTest.setView(
state: EnterNumberState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt
new file mode 100644
index 0000000000..f92cf66102
--- /dev/null
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.linknewdevice.impl.screens.qrcode
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
+import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
+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.lambda.lambdaRecorder
+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 ShowQrCodePresenterTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ createPresenter().test {
+ val initialState = awaitItem()
+ assertThat(initialState.data.dataOrNull()).isEqualTo("DATA")
+ }
+ }
+
+ @Test
+ fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val createLinkMobileHandlerResult = lambdaRecorder> {
+ Result.success(linkMobileHandler)
+ }
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = createLinkMobileHandlerResult,
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrRotating
+ )
+ runCurrent()
+ val finalState = awaitItem()
+ assertThat(finalState.data.isLoading()).isTrue()
+ createLinkMobileHandlerResult.assertions().isCalledExactly(2)
+ }
+ }
+
+ @Test
+ fun `present - when handler emits QrRotating, the presenter requests to rotate the QrCode and the code is rotated`() = runTest {
+ val linkMobileHandler = FakeLinkMobileHandler(
+ startResult = {},
+ )
+ val matrixClient = FakeMatrixClient(
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ linkNewMobileHandler.createAndStartNewHandler()
+ createPresenter(
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ awaitItem()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrRotating
+ )
+ runCurrent()
+ linkMobileHandler.emitStep(
+ LinkMobileStep.QrReady("DATA2")
+ )
+ val finalState = awaitItem()
+ assertThat(finalState.data.dataOrNull()).isEqualTo("DATA2")
+ }
+ }
+
+ private fun createPresenter(
+ linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
+ ) = ShowQrCodePresenter(
+ initialData = "DATA",
+ linkNewMobileHandler = linkNewMobileHandler,
+ )
+}
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
index c6c89ba818..7927eeed77 100644
--- 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
@@ -5,41 +5,39 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
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.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
onBackClick = callback
)
- rule.pressBackKey()
+ pressBackKey()
}
}
- private fun AndroidComposeTestRule.setView(
+ private fun AndroidComposeUiTest.setView(
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ShowQrCodeView(
- data = "DATA",
+ state = aShowQrCodeState(),
onBackClick = onBackClick,
)
}
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
index e352debfb0..bceb8753b2 100644
--- 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
@@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
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.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.architecture.AsyncData
@@ -19,74 +22,69 @@ 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`() {
+ fun `on back pressed - calls the onRetry callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLinkNewDeviceRootView(
+ setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
eventSink = eventRecorder,
),
onBackClick = callback
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `link desktop button clicked - calls the expected callback`() {
+ fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLinkNewDeviceRootView(
+ setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
onLinkDesktopDeviceClick = callback,
)
- rule.clickOn(R.string.screen_link_new_device_root_desktop_computer)
+ clickOn(R.string.screen_link_new_device_root_desktop_computer)
}
}
@Test
- fun `link mobile button clicked - emits the expected event`() {
+ fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder()
- rule.setLinkNewDeviceRootView(
+ setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
)
)
- rule.clickOn(R.string.screen_link_new_device_root_mobile_device)
+ clickOn(R.string.screen_link_new_device_root_mobile_device)
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
}
@Test
- fun `not supported - dismiss click - invokes the expected callback`() {
+ fun `not supported - dismiss click - invokes the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLinkNewDeviceRootView(
+ setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(false),
eventSink = eventRecorder,
),
onBackClick = callback,
)
- rule.clickOn(CommonStrings.action_dismiss)
+ clickOn(CommonStrings.action_dismiss)
}
}
- private fun AndroidComposeTestRule.setLinkNewDeviceRootView(
+ private fun AndroidComposeUiTest.setLinkNewDeviceRootView(
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
onBackClick: () -> Unit = EnsureNeverCalled(),
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
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
index fcc3afeb7d..1932718fef 100644
--- 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
@@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
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.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -19,44 +22,39 @@ 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`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setView(
+ setView(
state = aScanQrCodeState(
eventSink = eventRecorder,
),
onBackClick = callback
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `try again button clicked - emits the expected event`() {
+ fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder()
- rule.setView(
+ setView(
state = aScanQrCodeState(
scanAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventRecorder,
)
)
- rule.clickOn(CommonStrings.action_try_again)
+ clickOn(CommonStrings.action_try_again)
eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain)
}
- private fun AndroidComposeTestRule.setView(
+ private fun AndroidComposeUiTest.setView(
state: ScanQrCodeState = aScanQrCodeState(),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts
index ab85e37594..f8377389f1 100644
--- a/features/location/api/build.gradle.kts
+++ b/features/location/api/build.gradle.kts
@@ -71,6 +71,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
+ implementation(libs.datetime)
testCommonDependencies(libs)
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt
new file mode 100644
index 0000000000..8c53550737
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LiveLocationSharingBanner.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.location.api
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+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.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.ButtonSize
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun LiveLocationSharingBanner(
+ onClick: () -> Unit,
+ onStopClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(ElementTheme.colors.bgCanvasDefault)
+ .drawBannerBorder(ElementTheme.colors.separatorPrimary)
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = CompoundIcons.LocationPinSolid(),
+ contentDescription = null,
+ tint = ElementTheme.colors.iconAccentPrimary,
+ modifier = Modifier.size(24.dp),
+ )
+ Text(
+ text = stringResource(CommonStrings.screen_room_live_location_banner),
+ style = ElementTheme.typography.fontBodyMdMedium,
+ color = ElementTheme.colors.textPrimary,
+ )
+ }
+ Button(
+ text = stringResource(CommonStrings.action_stop),
+ onClick = onStopClick,
+ destructive = true,
+ size = ButtonSize.Small,
+ )
+ }
+}
+
+private fun Modifier.drawBannerBorder(borderColor: Color): Modifier = drawBehind {
+ val strokeWidth = 1.dp.toPx()
+ val bottomY = size.height - strokeWidth / 2
+ drawLine(
+ color = borderColor,
+ start = Offset(0f, strokeWidth / 2),
+ end = Offset(size.width, strokeWidth / 2),
+ strokeWidth = strokeWidth,
+ )
+ drawLine(
+ color = borderColor,
+ start = Offset(0f, bottomY),
+ end = Offset(size.width, bottomY),
+ strokeWidth = strokeWidth,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LiveLocationSharingBannerPreview() = ElementPreview {
+ LiveLocationSharingBanner(
+ onClick = {},
+ onStopClick = {},
+ )
+}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt
index 1227ddec46..3feeeff57d 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt
@@ -24,5 +24,7 @@ sealed interface ShowLocationMode : Parcelable {
) : ShowLocationMode
@Parcelize
- data object Live : ShowLocationMode
+ data class Live(
+ val senderId: UserId
+ ) : ShowLocationMode
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
index 0657bae634..a61cbe1c24 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt
@@ -9,7 +9,9 @@
package io.element.android.features.location.api
import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -22,6 +24,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.Extras
import coil3.compose.AsyncImagePainter
@@ -38,11 +42,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Shows a static map image downloaded via a third party service's static maps API.
+ *
+ * Handles 4 distinct cases:
+ * 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
+ * 2. Null location - shows blurred placeholder, no pin, no loading
+ * 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
+ * 4. Success (location != null, loaded) - shows actual map with pin
*/
@Composable
fun StaticMapView(
- lat: Double,
- lon: Double,
+ location: Location?,
zoom: Double,
pinVariant: PinVariant,
contentDescription: String?,
@@ -56,50 +65,111 @@ fun StaticMapView(
modifier = modifier,
contentAlignment = Alignment.Center
) {
- val context = LocalContext.current
- var retryHash by remember { mutableIntStateOf(0) }
- val builder = remember { StaticMapUrlBuilder() }
- val painter = rememberAsyncImagePainter(
- model = if (constraints.isZero) {
- // Avoid building a URL if any of the size constraints is zero (else it will thrown an exception).
- null
- } else {
- ImageRequest.Builder(context)
- .data(
- builder.build(
- lat = lat,
- lon = lon,
- zoom = zoom,
- darkMode = darkMode,
- width = constraints.maxWidth,
- height = constraints.maxHeight,
- density = LocalDensity.current.density,
- )
- )
- .size(width = constraints.maxWidth, height = constraints.maxHeight)
- .apply {
- extras.set(Extras.Key("retry_hash"), retryHash).build()
- }
- .build()
+ // Case 1: Stale location - show stale map with stale pin, no fetching
+ when {
+ pinVariant is PinVariant.StaleLocation -> {
+ StaleMapContent(
+ pinVariant = pinVariant,
+ contentDescription = contentDescription,
+ width = maxWidth,
+ height = maxHeight,
+ )
}
- )
+ // Case 2: Null location - show blurred placeholder, no pin, no loading
+ location == null -> {
+ StaticMapPlaceholder(
+ painter = painterResource(R.drawable.blurred_map),
+ canReload = false,
+ contentDescription = contentDescription,
+ width = maxWidth,
+ height = maxHeight,
+ onLoadMapClick = {}
+ )
+ }
+ // Cases 3 & 4: Non-null location - fetch map
+ else -> LoadableMapContent(
+ location = location,
+ zoom = zoom,
+ pinVariant = pinVariant,
+ contentDescription = contentDescription,
+ darkMode = darkMode,
+ )
+ }
+ }
+}
- val collectedState = painter.state.collectAsState()
- if (collectedState.value is AsyncImagePainter.State.Success) {
+@Composable
+private fun BoxWithConstraintsScope.StaleMapContent(
+ pinVariant: PinVariant,
+ contentDescription: String?,
+ width: Dp,
+ height: Dp,
+) {
+ Box(contentAlignment = Alignment.Center) {
+ Image(
+ painter = painterResource(R.drawable.stale_map),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.FillBounds,
+ modifier = Modifier.size(width = width, height = height)
+ )
+ LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this@StaleMapContent))
+ }
+}
+
+@Composable
+private fun BoxWithConstraintsScope.LoadableMapContent(
+ location: Location,
+ zoom: Double,
+ pinVariant: PinVariant,
+ contentDescription: String?,
+ darkMode: Boolean,
+) {
+ val context = LocalContext.current
+ var retryHash by remember { mutableIntStateOf(0) }
+ val builder = remember { StaticMapUrlBuilder() }
+
+ val painter = rememberAsyncImagePainter(
+ model = if (constraints.isZero) {
+ // Avoid building a URL if any of the size constraints is zero
+ null
+ } else {
+ ImageRequest.Builder(context)
+ .data(
+ builder.build(
+ lat = location.lat,
+ lon = location.lon,
+ zoom = zoom,
+ darkMode = darkMode,
+ width = constraints.maxWidth,
+ height = constraints.maxHeight,
+ density = LocalDensity.current.density,
+ )
+ )
+ .size(width = constraints.maxWidth, height = constraints.maxHeight)
+ .apply {
+ extras.set(Extras.Key("retry_hash"), retryHash).build()
+ }
+ .build()
+ }
+ )
+
+ val state by painter.state.collectAsState()
+ when (state) {
+ is AsyncImagePainter.State.Success -> {
Image(
painter = painter,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
// The returned image can be smaller than the requested size due to the static maps API having
- // a max width and height of 2048 px. See buildStaticMapsApiUrl() for more details.
- // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case.
+ // a max width and height of 2048 px. We apply ContentScale.Fit to handle this.
contentScale = ContentScale.Fit,
)
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
- } else {
+ }
+ else -> {
StaticMapPlaceholder(
- showProgress = collectedState.value.isLoading(),
- canReload = builder.isServiceAvailable(),
+ painter = painterResource(R.drawable.blurred_map),
+ canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
@@ -109,17 +179,11 @@ fun StaticMapView(
}
}
-private fun AsyncImagePainter.State.isLoading(): Boolean {
- return this is AsyncImagePainter.State.Empty ||
- this is AsyncImagePainter.State.Loading
-}
-
@PreviewsDayNight
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
- lat = 0.0,
- lon = 0.0,
+ location = Location(0.0, 0.0),
zoom = 0.0,
contentDescription = null,
pinVariant = PinVariant.PinnedLocation,
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
index 81b80c8dc3..0292ec927e 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -27,14 +28,13 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-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.ui.strings.CommonStrings
@Composable
internal fun StaticMapPlaceholder(
- showProgress: Boolean,
+ painter: Painter,
canReload: Boolean,
contentDescription: String?,
width: Dp,
@@ -46,17 +46,15 @@ internal fun StaticMapPlaceholder(
contentAlignment = Alignment.Center,
modifier = modifier
.size(width = width, height = height)
- .then(if (showProgress) Modifier else Modifier.clickable(onClick = onLoadMapClick))
+ .clickable(enabled = canReload, onClick = onLoadMapClick)
) {
Image(
- painter = painterResource(id = R.drawable.blurred_map),
+ painter = painter,
contentDescription = contentDescription,
contentScale = ContentScale.FillBounds,
modifier = Modifier.size(width = width, height = height)
)
- if (showProgress) {
- CircularProgressIndicator()
- } else if (canReload) {
+ if (canReload) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -77,13 +75,10 @@ internal fun StaticMapPlaceholderPreview() = ElementPreview {
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- listOf(
- true to false,
- false to true,
- false to false,
- ).forEach { (showProgress, canReload) ->
+ listOf(false, true)
+ .forEach { canReload ->
StaticMapPlaceholder(
- showProgress = showProgress,
+ painter = painterResource(R.drawable.blurred_map),
canReload = canReload,
contentDescription = null,
width = 400.dp,
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt
new file mode 100644
index 0000000000..cd6b8731c1
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt
@@ -0,0 +1,43 @@
+/*
+ * 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.location.api.live
+
+import io.element.android.libraries.core.coroutine.mapState
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.coroutines.flow.StateFlow
+import kotlin.time.Duration
+
+interface ActiveLiveLocationShareManager {
+ /** All rooms currently sharing live location on this device. */
+ val sharingRoomIds: StateFlow>
+
+ /**
+ * Initializes the manager.
+ * This will restart or stop current location sharing and set the listener on the SDK
+ * and the session manager.
+ */
+ suspend fun setup()
+
+ /**
+ * Starts live location sharing in the given room.
+ * Calls room.startLiveLocationShare() on the SDK, registers the share,
+ * and starts the foreground GPS service if not already running.
+ */
+ suspend fun startShare(roomId: RoomId, duration: Duration): Result
+
+ /**
+ * Stops live location sharing in the given room.
+ * Calls room.stopLiveLocationShare() on the SDK, removes the share,
+ * and stops the foreground service if no shares remain.
+ */
+ suspend fun stopShare(roomId: RoomId): Result
+}
+
+fun ActiveLiveLocationShareManager.isCurrentlySharing(roomId: RoomId): StateFlow {
+ return sharingRoomIds.mapState { roomId in it }
+}
diff --git a/features/location/api/src/main/res/drawable-night/stale_map.png b/features/location/api/src/main/res/drawable-night/stale_map.png
new file mode 100644
index 0000000000..9e36759203
Binary files /dev/null and b/features/location/api/src/main/res/drawable-night/stale_map.png differ
diff --git a/features/location/api/src/main/res/drawable/stale_map.png b/features/location/api/src/main/res/drawable/stale_map.png
new file mode 100644
index 0000000000..87fa0188c9
Binary files /dev/null and b/features/location/api/src/main/res/drawable/stale_map.png differ
diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts
index 0da54a1394..165c32b7c5 100644
--- a/features/location/impl/build.gradle.kts
+++ b/features/location/impl/build.gradle.kts
@@ -37,10 +37,16 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.matrixui)
implementation(projects.services.analytics.api)
+ implementation(projects.services.appnavstate.api)
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.dateformatter.api)
+ implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.push.api)
+ implementation(projects.libraries.sessionStorage.api)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.datetime)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
@@ -50,4 +56,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.sessionStorage.test)
+ testImplementation(projects.features.location.test)
}
diff --git a/features/location/impl/src/main/AndroidManifest.xml b/features/location/impl/src/main/AndroidManifest.xml
index ae728c09e1..e92ca68077 100644
--- a/features/location/impl/src/main/AndroidManifest.xml
+++ b/features/location/impl/src/main/AndroidManifest.xml
@@ -9,4 +9,14 @@
+
+
+
+
+
+
+
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt
index a0b0cd4734..f90793b775 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt
@@ -16,13 +16,16 @@ sealed interface LocationConstraintsCheck {
data object PermissionRationale : LocationConstraintsCheck
data object PermissionDenied : LocationConstraintsCheck
data object LocationServiceDisabled : LocationConstraintsCheck
+ data object NotEnoughPowerLevel : LocationConstraintsCheck
}
fun checkLocationConstraints(
permissionsState: PermissionsState,
locationActions: LocationActions,
+ sendLiveLocationPermissions: SendLiveLocationPermissions,
): LocationConstraintsCheck {
return when {
+ !sendLiveLocationPermissions.hasAll -> LocationConstraintsCheck.NotEnoughPowerLevel
permissionsState.isAnyGranted -> {
if (locationActions.isLocationEnabled()) {
LocationConstraintsCheck.Success
@@ -41,5 +44,6 @@ fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale
LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied
LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled
+ LocationConstraintsCheck.NotEnoughPowerLevel -> LocationConstraintsDialogState.NotEnoughPowerLevel
}
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt
new file mode 100644
index 0000000000..d1a9e32026
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/SendLiveLocationPermissions.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.location.impl.common
+
+import io.element.android.libraries.matrix.api.room.MessageEventType
+import io.element.android.libraries.matrix.api.room.StateEventType
+import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions
+
+/**
+ * Permissions to send beacon and beacon_info events in the room.
+ */
+data class SendLiveLocationPermissions(
+ val canSendBeacon: Boolean,
+ val canSendBeaconInfo: Boolean,
+) {
+ val hasAll = canSendBeaconInfo && canSendBeacon
+
+ companion object {
+ val DEFAULT = SendLiveLocationPermissions(canSendBeacon = false, canSendBeaconInfo = false)
+ val GRANTED = SendLiveLocationPermissions(canSendBeacon = true, canSendBeaconInfo = true)
+ }
+}
+
+fun RoomPermissions.sendLiveLocationPermissions(): SendLiveLocationPermissions {
+ return SendLiveLocationPermissions(
+ canSendBeaconInfo = canOwnUserSendState(StateEventType.BeaconInfo),
+ canSendBeacon = canOwnUserSendMessage(MessageEventType.Beacon),
+ )
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt
index 95f5129f91..334aebaee6 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt
@@ -10,6 +10,8 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.stringResource
+import io.element.android.features.location.impl.R
+import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@@ -42,6 +44,10 @@ fun LocationConstraintsDialog(
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
)
+ LocationConstraintsDialogState.NotEnoughPowerLevel -> AlertDialog(
+ content = stringResource(R.string.screen_share_location_live_location_missing_permissions),
+ onDismiss = onDismiss
+ )
}
}
@@ -51,4 +57,5 @@ sealed interface LocationConstraintsDialogState {
data object PermissionRationale : LocationConstraintsDialogState
data object PermissionDenied : LocationConstraintsDialogState
data object LocationServiceDisabled : LocationConstraintsDialogState
+ data object NotEnoughPowerLevel : LocationConstraintsDialogState
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt
index b949f55c76..24476e3c66 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -31,6 +32,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
+import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
@@ -42,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LocationShareRow(
item: LocationShareItem,
onShareClick: () -> Unit,
+ onStopClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
@@ -91,19 +95,32 @@ fun LocationShareRow(
)
}
Text(
- text = item.formattedTimestamp,
+ text = if (item.isLive) stringResource(CommonStrings.screen_room_live_location_banner) else item.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular,
- color = ElementTheme.colors.textSecondary,
+ color = if (item.isLive) ElementTheme.colors.textPrimary else ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
+ if (item.canStopSharing) {
+ IconButton(
+ onClick = onStopClick,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = ElementTheme.colors.bgCriticalPrimary,
+ contentColor = ElementTheme.colors.iconOnSolidPrimary,
+ )
+ ) {
+ Icon(
+ imageVector = CompoundIcons.Stop(),
+ contentDescription = stringResource(CommonStrings.action_stop),
+ )
+ }
+ }
IconButton(onClick = onShareClick) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
- tint = ElementTheme.colors.iconPrimary,
)
}
}
@@ -116,35 +133,39 @@ internal fun LocationShareRowPreview() = ElementPreview {
LocationShareRow(
item = LocationShareItem(
userId = UserId("@alice:matrix.org"),
- displayName = "Alice",
+ displayName = USER_NAME_ALICE,
avatarData = AvatarData(
id = "@alice:matrix.org",
- name = "Alice",
+ name = USER_NAME_ALICE,
url = null,
size = AvatarSize.UserListItem,
),
formattedTimestamp = "Shared 1 min ago",
isLive = true,
assetType = AssetType.SENDER,
- location = Location(0.0, 0.0)
+ location = Location(0.0, 0.0),
+ isOwnUser = true,
),
+ onStopClick = {},
onShareClick = {},
)
LocationShareRow(
item = LocationShareItem(
userId = UserId("@bob:matrix.org"),
- displayName = "Bob",
+ displayName = USER_NAME_BOB,
avatarData = AvatarData(
id = "@bob:matrix.org",
- name = "Bob",
+ name = USER_NAME_BOB,
url = null,
size = AvatarSize.UserListItem,
),
isLive = false,
assetType = AssetType.PIN,
formattedTimestamp = "Shared 5 hours ago",
- location = Location(0.0, 0.0)
+ location = Location(0.0, 0.0),
+ isOwnUser = false
),
+ onStopClick = {},
onShareClick = {},
)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt
index fbaed9c854..13c30c28eb 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt
@@ -10,12 +10,14 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
@@ -43,6 +45,7 @@ import androidx.compose.ui.unit.max
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.core.data.tryOrNull
+import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
@@ -112,8 +115,11 @@ fun MapBottomSheetScaffold(
modifier = Modifier,
sheetPeekHeight = sheetPeekHeight,
sheetContent = {
- sheetContent(sheetPadding)
- Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
+ val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
+ Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
+ sheetContent(sheetPadding)
+ Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
+ }
},
scaffoldState = scaffoldState,
sheetDragHandle = sheetDragHandle,
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt
index 8b89f77be4..589ed87c6f 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt
@@ -23,7 +23,7 @@ import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.location.rememberAndroidLocationProvider
import org.maplibre.compose.location.rememberNullLocationProvider
import org.maplibre.compose.location.rememberUserLocationState
-import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
@Composable
fun UserLocationPuck(
@@ -72,9 +72,9 @@ fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState
rememberNullLocationProvider()
} else {
rememberAndroidLocationProvider(
- updateInterval = 1.minutes,
- desiredAccuracy = DesiredAccuracy.Balanced,
- minDistanceMeters = 50f,
+ updateInterval = 5.seconds,
+ desiredAccuracy = DesiredAccuracy.High,
+ minDistanceMeters = 5f,
)
}
return rememberUserLocationState(locationProvider)
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindings.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt
similarity index 58%
rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindings.kt
rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt
index 49b2c43bc7..ee70936160 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushBindings.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/di/LocationBindings.kt
@@ -5,13 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.push.impl.di
+package io.element.android.features.location.impl.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
-import io.element.android.libraries.push.impl.push.FetchPushForegroundService
+import io.element.android.features.location.impl.live.service.LiveLocationSharingService
@ContributesTo(AppScope::class)
-interface PushBindings {
- fun inject(fetchPushForegroundService: FetchPushForegroundService)
+interface LocationBindings {
+ fun inject(service: LiveLocationSharingService)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt
new file mode 100644
index 0000000000..fd16bea515
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt
@@ -0,0 +1,227 @@
+/*
+ * 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.location.impl.live
+
+import dev.zacsweers.metro.ContributesBinding
+import dev.zacsweers.metro.SingleIn
+import dev.zacsweers.metro.binding
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
+import io.element.android.features.location.impl.live.service.LiveLocationReceiver
+import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
+import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.MatrixClient
+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.location.BeaconId
+import io.element.android.libraries.matrix.api.room.location.LiveLocationException
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.getAndUpdate
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.concurrent.atomics.AtomicBoolean
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.time.Duration
+import kotlin.time.Instant
+
+@OptIn(ExperimentalAtomicApi::class)
+@SingleIn(SessionScope::class)
+@ContributesBinding(SessionScope::class, binding = binding())
+class DefaultActiveLiveLocationShareManager(
+ private val matrixClient: MatrixClient,
+ private val coordinator: LiveLocationSharingCoordinator,
+ private val liveLocationStore: LiveLocationStore,
+ private val clock: SystemClock,
+ private val sessionObserver: SessionObserver,
+) : ActiveLiveLocationShareManager, LiveLocationReceiver {
+ private val isSetup = AtomicBoolean(false)
+ private val cachedRooms = ConcurrentHashMap()
+ private val timeoutJobs = ConcurrentHashMap()
+ private val syncedActiveShareIds = MutableStateFlow>(emptySet())
+ private val localSharingRoomIds = MutableStateFlow>(emptySet())
+ override val sharingRoomIds: StateFlow> = localSharingRoomIds
+
+ override suspend fun setup() = withContext(NonCancellable) {
+ if (isSetup.compareAndSet(expectedValue = false, newValue = true)) {
+ Timber.d("ActiveLiveLocationShareManager setup manager.")
+
+ recoverPersistedShares()
+
+ matrixClient.ownBeaconInfoUpdates
+ .onEach { update ->
+ Timber.d("Received beaconInfoUpdate:$update")
+ // First cancel the local share in this room if any.
+ if (update.roomId in localSharingRoomIds.value) {
+ stopLocalShare(roomId = update.roomId)
+ }
+ syncedActiveShareIds.update {
+ if (update.isLive) {
+ it + update.beaconId
+ } else {
+ it - update.beaconId
+ }
+ }
+ }
+ .launchIn(matrixClient.sessionCoroutineScope)
+
+ sessionObserver.addListener(sessionListener)
+ }
+ }
+
+ private val sessionListener: SessionListener = object : SessionListener {
+ override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
+ if (matrixClient.sessionId.value == userId) {
+ clear()
+ }
+ }
+ }
+
+ override suspend fun startShare(roomId: RoomId, duration: Duration): Result = withContext(NonCancellable) {
+ Timber.d("ActiveLiveLocationShareManager starting share for room $roomId with duration ${duration.inWholeSeconds}s")
+ val room = cachedRooms.getOrPut(roomId) {
+ matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
+ }
+ // Before starting a new location share, stop the current one if any is active.
+ room.stopLiveLocationShare()
+
+ room.startLiveLocationShare(duration.inWholeMilliseconds)
+ .onSuccess { beaconId ->
+ Timber.d("ActiveLiveLocationShareManager wait remote echo of $beaconId")
+ syncedActiveShareIds.first { beaconIds -> beaconIds.contains(beaconId) }
+ val expiresAt = Instant.fromEpochMilliseconds(clock.epochMillis() + duration.inWholeMilliseconds)
+ startLocalShare(roomId, expiresAt)
+ }
+ .onFailure {
+ Timber.e(it, "ActiveLiveLocationShareManager failed to start share for room $roomId")
+ stopLocalShare(roomId)
+ }
+ .map { }
+ }
+
+ override suspend fun stopShare(roomId: RoomId): Result = withContext(NonCancellable) {
+ Timber.d("ActiveLiveLocationShareManager stopping share for room $roomId")
+ val room = cachedRooms.getOrPut(roomId) {
+ matrixClient.getJoinedRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("No room found for $roomId"))
+ }
+ room.stopLiveLocationShare()
+ .onSuccess {
+ Timber.d("ActiveLiveLocationShareManager share stopped successfully for room $roomId")
+ }
+ .onFailure {
+ Timber.e(it, "ActiveLiveLocationShareManager failed to stop share for room $roomId")
+ }
+ .also {
+ stopLocalShare(roomId)
+ }
+ }
+
+ override suspend fun onLocationUpdate(location: Location) {
+ val activeSharesCount = localSharingRoomIds.value.size
+ Timber.d("ActiveLiveLocationShareManager received location update for $activeSharesCount active share(s)")
+ localSharingRoomIds.value.forEach { roomId ->
+ Timber.d("ActiveLiveLocationShareManager sending location to room $roomId")
+ sendLiveLocation(roomId, location)
+ .onFailure {
+ Timber.e(it, "ActiveLiveLocationShareManager failed to send location to room $roomId")
+ }
+ }
+ }
+
+ private suspend fun sendLiveLocation(roomId: RoomId, location: Location): Result {
+ val room = cachedRooms.getOrPut(roomId) {
+ matrixClient.getJoinedRoom(roomId) ?: return Result.failure(IllegalStateException("No room found for $roomId"))
+ }
+ return room.sendLiveLocation(location.toGeoUri())
+ .recoverCatching { exception ->
+ when (exception) {
+ is LiveLocationException.NotLive -> {
+ stopLocalShare(roomId)
+ throw exception
+ }
+ else -> throw exception
+ }
+ }
+ }
+
+ private suspend fun startLocalShare(roomId: RoomId, expiresAt: Instant) {
+ val wasEmpty = localSharingRoomIds.value.isEmpty()
+ Timber.d("ActiveLiveLocationShareManager share started successfully for room $roomId (wasEmpty=$wasEmpty)")
+ localSharingRoomIds.update { it + roomId }
+ liveLocationStore.setLiveLocationExpiry(roomId, expiresAt)
+ scheduleTimeout(roomId, expiresAt)
+ if (wasEmpty) {
+ Timber.d("ActiveLiveLocationShareManager registering with coordinator for session ${matrixClient.sessionId}")
+ coordinator.register(matrixClient.sessionId, this@DefaultActiveLiveLocationShareManager)
+ }
+ }
+
+ private suspend fun recoverPersistedShares() {
+ val now = Instant.fromEpochMilliseconds(clock.epochMillis())
+ liveLocationStore.getLiveLocationExpiries().forEach { (roomId, expiresAt) ->
+ if (expiresAt > now) {
+ // Only starts locally as the share is already started remotely
+ startLocalShare(roomId, expiresAt)
+ } else {
+ // Explicitly stop the share on the server.
+ stopShare(roomId)
+ }
+ }
+ }
+
+ private fun scheduleTimeout(roomId: RoomId, expiresAt: Instant) {
+ timeoutJobs.remove(roomId)?.cancel()
+ val delayMillis = expiresAt.toEpochMilliseconds() - clock.epochMillis()
+ timeoutJobs[roomId] = matrixClient.sessionCoroutineScope.launch {
+ delay(delayMillis)
+ stopShare(roomId)
+ .onFailure { error ->
+ Timber.e(error, "ActiveLiveLocationShareManager failed to stop timed out share for room $roomId")
+ }
+ }
+ }
+
+ private suspend fun stopLocalShare(roomId: RoomId) {
+ Timber.d("ActiveLiveLocationShareManager stop local share in $roomId")
+ timeoutJobs.remove(roomId)?.cancel()
+ val wasSharing = localSharingRoomIds.getAndUpdate { it - roomId }.isNotEmpty()
+ cachedRooms.remove(roomId)?.close()
+ liveLocationStore.removeLiveLocationExpiry(roomId)
+ if (wasSharing && localSharingRoomIds.value.isEmpty()) {
+ Timber.d("ActiveLiveLocationShareManager unregistering from coordinator for session ${matrixClient.sessionId}")
+ coordinator.unregister(matrixClient.sessionId)
+ }
+ }
+
+ private suspend fun clear() {
+ Timber.d("ActiveLiveLocationShareManager clear state")
+ sessionObserver.removeListener(sessionListener)
+ coordinator.unregister(matrixClient.sessionId)
+ liveLocationStore.clear()
+ for (room in cachedRooms.values) {
+ room.close()
+ timeoutJobs[room.roomId]?.cancel()
+ }
+ timeoutJobs.clear()
+ cachedRooms.clear()
+ localSharingRoomIds.value = emptySet()
+ syncedActiveShareIds.value = emptySet()
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt
new file mode 100644
index 0000000000..417d9d423a
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/LiveLocationStore.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.location.impl.live
+
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.libraries.androidutils.hash.hash
+import io.element.android.libraries.core.extensions.runCatchingExceptions
+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.first
+import timber.log.Timber
+import kotlin.time.Instant
+
+private const val LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR = "="
+
+@Inject
+@SingleIn(SessionScope::class)
+class LiveLocationStore(
+ preferenceDataStoreFactory: PreferenceDataStoreFactory,
+ sessionId: SessionId,
+) {
+ private val store = preferenceDataStoreFactory.create("location_${sessionId.value.hash().take(16)}")
+ private val acceptedLiveLocationDisclaimerKey = booleanPreferencesKey("live_location_disclaimer_accepted")
+ private val liveLocationExpiriesKey = stringSetPreferencesKey("live_location_expiries")
+
+ suspend fun hasAcceptedLiveLocationDisclaimer(): Boolean = runCatchingExceptions {
+ store.data.first()[acceptedLiveLocationDisclaimerKey] ?: false
+ }.getOrDefault(false)
+
+ suspend fun setAcceptedLiveLocationDisclaimer(): Result = runCatchingExceptions {
+ store.edit { prefs ->
+ prefs[acceptedLiveLocationDisclaimerKey] = true
+ }
+ }
+
+ suspend fun getLiveLocationExpiries(): Map = runCatchingExceptions {
+ val serialized = store.data.first()[liveLocationExpiriesKey].orEmpty()
+ decodeLiveLocationExpiries(serialized)
+ }.onFailure { error ->
+ Timber.e(error, "Failed to decode live location expiry payload")
+ }.getOrDefault(emptyMap())
+
+ suspend fun setLiveLocationExpiry(roomId: RoomId, expiresAt: Instant): Result = runCatchingExceptions {
+ store.edit { prefs ->
+ val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
+ prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(current + (roomId to expiresAt))
+ }
+ }
+
+ suspend fun removeLiveLocationExpiry(roomId: RoomId): Result = runCatchingExceptions {
+ store.edit { prefs ->
+ val current = decodeLiveLocationExpiries(prefs[liveLocationExpiriesKey].orEmpty())
+ val updated = current - roomId
+ if (updated.isEmpty()) {
+ prefs.remove(liveLocationExpiriesKey)
+ } else {
+ prefs[liveLocationExpiriesKey] = encodeLiveLocationExpiries(updated)
+ }
+ }
+ }
+
+ private fun decodeLiveLocationExpiries(serialized: Set): Map {
+ return runCatchingExceptions {
+ serialized
+ .map { it.split(LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR) }
+ .associate { values ->
+ val roomId = RoomId(values[0])
+ val expiresAtMillis = values[1].toLong()
+ roomId to Instant.fromEpochMilliseconds(expiresAtMillis)
+ }
+ }.getOrDefault(emptyMap())
+ }
+
+ private fun encodeLiveLocationExpiries(expiries: Map): Set {
+ return expiries.entries.map { (roomId, expiresAt) ->
+ "${roomId.value}$LIVE_LOCATION_EXPIRY_VALUE_SEPARATOR${expiresAt.toEpochMilliseconds()}"
+ }.toSet()
+ }
+
+ suspend fun clear() {
+ store.edit { prefs -> prefs.clear() }
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt
new file mode 100644
index 0000000000..9d4c461b0e
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/notification/LiveLocationSharingNotificationCreator.kt
@@ -0,0 +1,61 @@
+/*
+ * 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.location.impl.live.notification
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import androidx.annotation.ChecksSdkIntAtLeast
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationCompat
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.core.meta.BuildMeta
+import io.element.android.libraries.di.annotations.ApplicationContext
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Inject
+class LiveLocationSharingNotificationCreator(
+ @ApplicationContext private val context: Context,
+ private val buildMeta: BuildMeta,
+) {
+ companion object {
+ const val CHANNEL_ID = "LIVE_LOCATION_SHARING"
+ }
+
+ fun createNotification(): Notification {
+ if (supportNotificationChannels()) {
+ ensureChannelExists()
+ }
+ return NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentTitle(context.getString(CommonStrings.live_location_sharing_foreground_service_title_android, buildMeta.applicationName))
+ .setContentText(context.getString(CommonStrings.live_location_sharing_foreground_service_message_android))
+ .setOngoing(true)
+ .build()
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun ensureChannelExists() {
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
+ notificationManager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ context.getString(CommonStrings.live_location_sharing_foreground_service_channel_title_android)
+ .ifEmpty { "Live Location Sharing" },
+ NotificationManager.IMPORTANCE_LOW,
+ )
+ )
+ }
+ }
+
+ @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
+ private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt
new file mode 100644
index 0000000000..adba75730c
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt
@@ -0,0 +1,14 @@
+/*
+ * 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.location.impl.live.service
+
+import io.element.android.features.location.api.Location
+
+fun interface LiveLocationReceiver {
+ suspend fun onLocationUpdate(location: Location)
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt
new file mode 100644
index 0000000000..e39acb14e8
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt
@@ -0,0 +1,98 @@
+/*
+ * 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.location.impl.live.service
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.ContextCompat
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.features.location.api.Location
+import io.element.android.libraries.core.extensions.runCatchingExceptions
+import io.element.android.libraries.di.annotations.ApplicationContext
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import timber.log.Timber
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.concurrent.atomics.AtomicLong
+import kotlin.concurrent.atomics.AtomicReference
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.time.Duration.Companion.seconds
+
+private val THROTTLE_WINDOW = 3.seconds
+
+@OptIn(ExperimentalAtomicApi::class)
+@SingleIn(AppScope::class)
+class LiveLocationSharingCoordinator internal constructor(
+ private val startService: () -> Unit,
+ private val stopService: () -> Unit,
+ private val nowMillis: () -> Long,
+) {
+ @Inject
+ constructor(@ApplicationContext context: Context, clock: SystemClock) : this(
+ startService = {
+ ContextCompat.startForegroundService(context, Intent(context, LiveLocationSharingService::class.java))
+ },
+ stopService = {
+ context.stopService(Intent(context, LiveLocationSharingService::class.java))
+ },
+ nowMillis = clock::epochMillis
+ )
+
+ private val receivers = ConcurrentHashMap()
+
+ private val lastDispatchMillis = AtomicLong(0L)
+ private val lastKnownLocation = AtomicReference(null)
+
+ suspend fun register(sessionId: SessionId, receiver: LiveLocationReceiver) {
+ val wasEmpty = receivers.isEmpty()
+ Timber.d("LiveLocationSharingCoordinator registering receiver for session $sessionId (wasEmpty=$wasEmpty)")
+ receivers[sessionId] = receiver
+ if (wasEmpty) {
+ Timber.d("LiveLocationSharingCoordinator starting service")
+ runCatchingExceptions(startService).onFailure {
+ Timber.e(it, "Failed to start live location sharing service")
+ }
+ }
+ lastKnownLocation.load()?.let {
+ dispatch(it)
+ }
+ }
+
+ fun unregister(sessionId: SessionId) {
+ Timber.d("LiveLocationSharingCoordinator unregistering receiver for session $sessionId")
+ receivers.remove(sessionId)
+ if (receivers.isEmpty()) {
+ lastKnownLocation.store(null)
+ Timber.d("LiveLocationSharingCoordinator stopping service (no more receivers)")
+ runCatchingExceptions(stopService).onFailure {
+ Timber.e(it, "Failed to stop live location sharing service")
+ }
+ }
+ }
+
+ suspend fun dispatch(location: Location) {
+ val currentTimeMillis = nowMillis()
+ val millisSincePrevious = currentTimeMillis - lastDispatchMillis.load()
+ if (millisSincePrevious < THROTTLE_WINDOW.inWholeMilliseconds) {
+ Timber.d("Received location before $THROTTLE_WINDOW, ignore.")
+ return
+ }
+ lastKnownLocation.store(location)
+ lastDispatchMillis.store(currentTimeMillis)
+ receivers.forEach { (sessionId, receiver) ->
+ Timber.d("Dispatch received location for session $sessionId ")
+ runCatchingExceptions {
+ receiver.onLocationUpdate(location)
+ }.onFailure {
+ Timber.e(it, "Failed to dispatch live location update for session $sessionId")
+ }
+ }
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt
new file mode 100644
index 0000000000..4451febb19
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt
@@ -0,0 +1,125 @@
+/*
+ * 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.location.impl.live.service
+
+import android.annotation.SuppressLint
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+import android.os.IBinder
+import androidx.core.app.ServiceCompat
+import dev.zacsweers.metro.Inject
+import io.element.android.features.location.impl.di.LocationBindings
+import io.element.android.features.location.impl.live.notification.LiveLocationSharingNotificationCreator
+import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.coroutine.childScope
+import io.element.android.libraries.core.extensions.runCatchingExceptions
+import io.element.android.libraries.di.annotations.AppCoroutineScope
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.push.api.notifications.ForegroundServiceType
+import io.element.android.libraries.push.api.notifications.NotificationIdProvider
+import io.element.android.services.appnavstate.api.AppForegroundStateService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import org.maplibre.compose.location.AndroidLocationProvider
+import org.maplibre.compose.location.DesiredAccuracy
+import timber.log.Timber
+import kotlin.time.Duration.Companion.seconds
+import io.element.android.features.location.api.Location as ApiLocation
+
+private const val UPDATE_INTERVAL_IN_SECOND = 10
+
+class LiveLocationSharingService : Service() {
+ @Inject lateinit var coordinator: LiveLocationSharingCoordinator
+ @Inject lateinit var notificationCreator: LiveLocationSharingNotificationCreator
+ @Inject lateinit var appPreferencesStore: AppPreferencesStore
+
+ @Inject lateinit var appForegroundStateService: AppForegroundStateService
+
+ @AppCoroutineScope
+ @Inject lateinit var appCoroutineScope: CoroutineScope
+ private lateinit var coroutineScope: CoroutineScope
+
+ override fun onBind(p0: Intent?): IBinder? = null
+
+ @OptIn(FlowPreview::class)
+ @SuppressLint("InlinedApi")
+ override fun onCreate() {
+ super.onCreate()
+ Timber.d("LiveLocationSharingService onCreate")
+ runCatchingExceptions {
+ bindings().inject(this)
+ appForegroundStateService.updateIsSharingLiveLocation(true)
+ coroutineScope = appCoroutineScope.childScope(Dispatchers.Default, "LiveLocationSharingService")
+ val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.LIVE_LOCATION)
+ Timber.d("LiveLocationSharingService starting foreground service with notificationId=$notificationId")
+ ServiceCompat.startForeground(
+ // service =
+ this,
+ // id =
+ notificationId,
+ // notification =
+ notificationCreator.createNotification(),
+ // foregroundServiceType =
+ FOREGROUND_SERVICE_TYPE_LOCATION,
+ )
+ startLocationUpdatesListener()
+ }.onFailure {
+ Timber.e(it, "Failed to start live location sharing service")
+ stopSelf()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun startLocationUpdatesListener() {
+ Timber.d("LiveLocationSharingService listening to location updates")
+ appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
+ .flatMapLatest { minDistanceMeters ->
+ val locationProvider = AndroidLocationProvider(
+ context = applicationContext,
+ updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
+ minDistanceMeters = minDistanceMeters.toFloat(),
+ desiredAccuracy = DesiredAccuracy.Balanced,
+ coroutineScope = coroutineScope
+ )
+ locationProvider.location
+ }
+ .filterNotNull()
+ .map { location ->
+ ApiLocation(
+ lat = location.position.latitude,
+ lon = location.position.longitude,
+ accuracy = location.accuracy.toFloat(),
+ )
+ }
+ .onEach(coordinator::dispatch)
+ .launchIn(coroutineScope)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Timber.d("LiveLocationSharingService onStartCommand startId=$startId")
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ Timber.d("LiveLocationSharingService onDestroy")
+ if (::coroutineScope.isInitialized) {
+ coroutineScope.cancel()
+ }
+ appForegroundStateService.updateIsSharingLiveLocation(false)
+ super.onDestroy()
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt
index d9ebc8b5af..e560ce805f 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt
@@ -17,7 +17,8 @@ sealed interface ShareLocationEvent {
val isPinned: Boolean,
) : ShareLocationEvent
- data object ShowLiveLocationDurationPicker : ShareLocationEvent
+ data object InitiateLiveLocationShare : ShareLocationEvent
+ data object AcceptLiveLocationDisclaimer : ShareLocationEvent
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
data object StartTrackingUserLocation : ShareLocationEvent
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt
index 10fddf1e50..a1e45cfea2 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt
@@ -21,26 +21,30 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
+import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
+import io.element.android.features.location.impl.common.SendLiveLocationPermissions
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsState
+import io.element.android.features.location.impl.common.sendLiveLocationPermissions
import io.element.android.features.location.impl.common.toDialogState
-import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints
+import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext
+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.extensions.flatMap
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DurationFormatter
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
+import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
@@ -60,9 +64,10 @@ class ShareLocationPresenter(
private val messageComposerContext: MessageComposerContext,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
- private val featureFlagService: FeatureFlagService,
private val client: MatrixClient,
private val durationFormatter: DurationFormatter,
+ private val liveLocationShareManager: ActiveLiveLocationShareManager,
+ private val liveLocationStore: LiveLocationStore,
) : Presenter {
@AssistedFactory
fun interface Factory {
@@ -75,22 +80,43 @@ class ShareLocationPresenter(
override fun present(): ShareLocationState {
val permissionsState: PermissionsState = permissionsPresenter.present()
var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) }
- val isLiveLocationSharingEnabled by remember {
- featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
- }.collectAsState(false)
val appName by remember { derivedStateOf { buildMeta.applicationName } }
var dialogState: ShareLocationState.Dialog by remember {
mutableStateOf(ShareLocationState.Dialog.None)
}
+ val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
+ val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
+ perms.sendLiveLocationPermissions()
+ }
val scope = rememberCoroutineScope()
fun checkLocationConstraints() {
- val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
- dialogState = Constraints(locationConstraints.toDialogState())
+ // No need to check SendLiveLocationPermissions here
+ val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
+ dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
}
+ suspend fun computeLiveLocationDialogState(): ShareLocationState.Dialog {
+ val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
+ val constraintsResult = checkLocationConstraints(permissionsState, locationActions, sendLiveLocationPermissions)
+ return when {
+ !hasAcceptedDisclaimer -> {
+ ShareLocationState.Dialog.LiveLocationDisclaimer
+ }
+ constraintsResult is LocationConstraintsCheck.Success -> {
+ val durations = LIVE_LOCATION_DURATIONS.map {
+ LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
+ }
+ ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
+ }
+ else -> {
+ ShareLocationState.Dialog.Constraints(constraintsResult.toDialogState())
+ }
+ }
+ }
+
LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
fun handleEvent(event: ShareLocationEvent) {
@@ -109,20 +135,23 @@ class ShareLocationPresenter(
locationActions.openLocationSettings()
dialogState = ShareLocationState.Dialog.None
}
- ShareLocationEvent.ShowLiveLocationDurationPicker -> {
- val constraintsResult = checkLocationConstraints(permissionsState, locationActions)
- dialogState = if (constraintsResult is LocationConstraintsCheck.Success) {
- val durations = LIVE_LOCATION_DURATIONS.map {
- LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
+ ShareLocationEvent.InitiateLiveLocationShare -> scope.launch {
+ dialogState = computeLiveLocationDialogState()
+ }
+ ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
+ liveLocationStore.setAcceptedLiveLocationDisclaimer()
+ .onSuccess {
+ dialogState = computeLiveLocationDialogState()
}
- ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
- } else {
- Constraints(constraintsResult.toDialogState())
- }
}
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
dialogState = ShareLocationState.Dialog.None
- // room.startLiveLocationShare(event.duration.inWholeMilliseconds)
+ startLiveLocationAction.runUpdatingState {
+ liveLocationShareManager.startShare(
+ roomId = room.roomId,
+ duration = event.duration,
+ )
+ }
}
ShareLocationEvent.RequestPermissions -> {
dialogState = ShareLocationState.Dialog.None
@@ -136,8 +165,9 @@ class ShareLocationPresenter(
dialogState = dialogState,
trackUserLocation = trackUserPosition,
hasLocationPermission = permissionsState.isAnyGranted,
- canShareLiveLocation = isLiveLocationSharingEnabled,
+ canShareLiveLocation = timelineMode.canShareLiveLocation(),
appName = appName,
+ startLiveLocationAction = startLiveLocationAction.value,
eventSink = ::handleEvent,
)
}
@@ -174,4 +204,9 @@ class ShareLocationPresenter(
}
}
+private fun Timeline.Mode.canShareLiveLocation() = when (this) {
+ is Timeline.Mode.Thread -> false
+ else -> true
+}
+
private fun generateBody(uri: String): String = "Location was shared at $uri"
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt
index 8b1f494f1e..68598cba04 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt
@@ -9,6 +9,7 @@
package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
@@ -19,11 +20,13 @@ data class ShareLocationState(
val hasLocationPermission: Boolean,
val appName: String,
val canShareLiveLocation: Boolean,
+ val startLiveLocationAction: AsyncAction,
val eventSink: (ShareLocationEvent) -> Unit,
) {
sealed interface Dialog {
data object None : Dialog
data class Constraints(val state: LocationConstraintsDialogState) : Dialog
+ data object LiveLocationDisclaimer : Dialog
data class LiveLocationDurations(val durations: ImmutableList) : Dialog
}
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt
index facef74346..ae1b765b6b 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt
@@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf
@@ -51,6 +52,18 @@ class ShareLocationStateProvider : PreviewParameterProvider
trackUserPosition = true,
hasLocationPermission = true,
),
+ aShareLocationState(
+ dialogState = ShareLocationState.Dialog.None,
+ trackUserPosition = true,
+ hasLocationPermission = true,
+ canShareLiveLocation = true,
+ ),
+ aShareLocationState(
+ dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
+ trackUserPosition = true,
+ hasLocationPermission = true,
+ canShareLiveLocation = true,
+ ),
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
persistentListOf(
@@ -73,6 +86,7 @@ fun aShareLocationState(
hasLocationPermission: Boolean = false,
canShareLiveLocation: Boolean = false,
appName: String = APP_NAME,
+ startLiveLocationAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
@@ -82,6 +96,7 @@ fun aShareLocationState(
hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation,
appName = appName,
+ startLiveLocationAction = startLiveLocationAction,
eventSink = eventSink
)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt
index 1e163f417d..e20ee3a7a5 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt
@@ -29,7 +29,6 @@ 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.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -44,11 +43,16 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.features.location.impl.common.ui.rememberUserLocationState
-import io.element.android.libraries.androidutils.system.toast
+import io.element.android.features.location.impl.share.ShareLocationEvent.StartLiveLocationShare
+import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
+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.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
@@ -74,7 +78,6 @@ fun ShareLocationView(
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
- val context = LocalContext.current
when (val dialogState = state.dialogState) {
ShareLocationState.Dialog.None -> Unit
is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog(
@@ -85,12 +88,17 @@ fun ShareLocationView(
onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) },
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
+ ShareLocationState.Dialog.LiveLocationDisclaimer -> ConfirmationDialog(
+ content = stringResource(R.string.screen_share_location_live_location_disclaimer_title),
+ submitText = stringResource(CommonStrings.action_accept),
+ cancelText = stringResource(CommonStrings.action_decline),
+ onSubmitClick = { state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer) },
+ onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
+ )
is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog(
durations = dialogState.durations,
onSelectDuration = { duration ->
- state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration))
- context.toast("Not implemented yet!")
- navigateUp()
+ state.eventSink(StartLiveLocationShare(duration))
},
onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) },
)
@@ -160,10 +168,46 @@ fun ShareLocationView(
.align(Alignment.TopEnd)
.padding(all = 16.dp),
)
+ StartLiveLocationActionView(state.startLiveLocationAction, navigateUp)
}
)
}
+@Composable
+private fun StartLiveLocationActionView(
+ action: AsyncAction,
+ onActionSuccess: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier) {
+ val asyncIndicatorState = rememberAsyncIndicatorState()
+ AsyncIndicatorHost(state = asyncIndicatorState)
+
+ when (action) {
+ is AsyncAction.Loading -> {
+ LaunchedEffect(action) {
+ asyncIndicatorState.enqueue {
+ AsyncIndicator.Loading(text = stringResource(CommonStrings.common_waiting_live_location))
+ }
+ }
+ }
+ is AsyncAction.Failure -> {
+ LaunchedEffect(action) {
+ asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
+ AsyncIndicator.Failure(
+ text = stringResource(CommonStrings.common_something_went_wrong),
+ )
+ }
+ }
+ }
+ is AsyncAction.Success -> {
+ LaunchedEffect(action) { onActionSuccess() }
+ }
+ else -> Unit
+ }
+ }
+}
+
@Composable
private fun BottomSheetContent(
cameraState: CameraState,
@@ -202,7 +246,7 @@ private fun BottomSheetContent(
}
if (state.canShareLiveLocation) {
ShareLiveLocationItem {
- state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
+ state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
}
}
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.kt
new file mode 100644
index 0000000000..41b9bea4ca
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparator.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.location.impl.show
+
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
+
+class LiveLocationShareComparator(private val currentUser: UserId) : Comparator {
+ override fun compare(p0: LiveLocationShare, p1: LiveLocationShare): Int {
+ val p0IsCurrentUser = p0.userId == currentUser
+ val p1IsCurrentUser = p1.userId == currentUser
+ if (p0IsCurrentUser != p1IsCurrentUser) return if (p0IsCurrentUser) -1 else 1
+ return p1.startTimestamp.compareTo(p0.startTimestamp)
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt
index 6a3e3521e0..34132dccf3 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt
@@ -17,4 +17,5 @@ sealed interface ShowLocationEvent {
data object RequestPermissions : ShowLocationEvent
data object OpenAppSettings : ShowLocationEvent
data object OpenLocationSettings : ShowLocationEvent
+ data object StopLocationSharing : ShowLocationEvent
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
index a2c9a3702d..207b4b01dd 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt
@@ -13,14 +13,19 @@ import androidx.compose.runtime.LaunchedEffect
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.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
+import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
+import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
+import io.element.android.features.location.impl.common.SendLiveLocationPermissions
import io.element.android.features.location.impl.common.actions.LocationActions
import io.element.android.features.location.impl.common.checkLocationConstraints
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@@ -29,14 +34,21 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.getBestName
+import io.element.android.libraries.matrix.api.room.joinedRoomMembers
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
@AssistedInject
class ShowLocationPresenter(
@@ -46,6 +58,8 @@ class ShowLocationPresenter(
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
+ private val joinedRoom: JoinedRoom,
+ private val liveLocationShareManager: ActiveLiveLocationShareManager,
) : Presenter {
@AssistedFactory
fun interface Factory {
@@ -56,6 +70,7 @@ class ShowLocationPresenter(
@Composable
override fun present(): ShowLocationState {
+ val coroutineScope = rememberCoroutineScope()
val permissionsState: PermissionsState = permissionsPresenter.present()
var isTrackMyLocation by remember { mutableStateOf(false) }
val appName by remember { derivedStateOf { buildMeta.applicationName } }
@@ -76,7 +91,7 @@ class ShowLocationPresenter(
}
is ShowLocationEvent.TrackMyLocation -> {
if (event.enabled) {
- val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
+ val locationConstraints = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
dialogState = locationConstraints.toDialogState()
} else {
@@ -93,12 +108,15 @@ class ShowLocationPresenter(
dialogState = LocationConstraintsDialogState.None
}
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
+ liveLocationShareManager.stopShare(joinedRoom.roomId)
+ }
}
}
- val locationShares = remember {
- when (mode) {
- is ShowLocationMode.Static -> {
+ val locationShares = when (mode) {
+ is ShowLocationMode.Static -> {
+ remember {
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = stringProvider.getString(
CommonStrings.screen_static_location_sheet_timestamp_description,
@@ -118,18 +136,64 @@ class ShowLocationPresenter(
location = mode.location,
isLive = false,
assetType = mode.assetType,
+ isOwnUser = mode.senderId == joinedRoom.sessionId
)
)
}
- ShowLocationMode.Live -> persistentListOf()
}
+ is ShowLocationMode.Live -> {
+ produceState(persistentListOf()) {
+ val comparator = LiveLocationShareComparator(currentUser = joinedRoom.sessionId)
+ val liveLocationSharesFlow = joinedRoom.subscribeToLiveLocationShares()
+ val membersStateFlow = joinedRoom.membersStateFlow.mapState { it.joinedRoomMembers() }
+ combine(liveLocationSharesFlow, membersStateFlow) { liveShares, members ->
+ liveShares
+ .sortedWith(comparator)
+ .mapNotNull { share ->
+ val lastLocation = share.lastLocation ?: return@mapNotNull null
+ val location = Location.fromGeoUri(lastLocation.geoUri) ?: return@mapNotNull null
+ val member = members.find { it.userId == share.userId }
+ val displayName = member?.getBestName() ?: share.userId.value
+ val avatarUrl = member?.avatarUrl
+ val relativeTime = dateFormatter.format(timestamp = lastLocation.timestamp, mode = DateFormatterMode.Full, useRelative = true)
+ val formattedTimestamp = stringProvider.getString(
+ CommonStrings.screen_static_location_sheet_timestamp_description,
+ relativeTime
+ )
+ LocationShareItem(
+ userId = share.userId,
+ displayName = displayName,
+ avatarData = AvatarData(
+ id = share.userId.value,
+ name = displayName,
+ url = avatarUrl,
+ size = AvatarSize.UserListItem,
+ ),
+ formattedTimestamp = formattedTimestamp,
+ location = location,
+ isLive = true,
+ assetType = lastLocation.assetType,
+ isOwnUser = share.userId == joinedRoom.sessionId
+ )
+ }
+ .toImmutableList()
+ }.collect { value = it }
+ }.value
+ }
+ }
+
+ val focusedLocation = when (mode) {
+ is ShowLocationMode.Static -> locationShares.firstOrNull()
+ is ShowLocationMode.Live -> locationShares.firstOrNull { it.userId == mode.senderId }
}
return ShowLocationState(
dialogState = dialogState,
locationShares = locationShares,
+ focusedLocation = focusedLocation,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
+ isLive = mode is ShowLocationMode.Live,
appName = appName,
eventSink = ::handleEvent,
)
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
index 9494db12ec..720697cbf7 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt
@@ -18,14 +18,16 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
+ val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList,
+ val focusedLocation: LocationShareItem?,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val appName: String,
val eventSink: (ShowLocationEvent) -> Unit,
) {
- val isSheetDraggable = locationShares.any { item -> item.isLive }
+ val isSheetDraggable = isLive && locationShares.isNotEmpty()
}
data class LocationShareItem(
@@ -36,7 +38,10 @@ data class LocationShareItem(
val location: Location,
val isLive: Boolean,
val assetType: AssetType?,
-)
+ val isOwnUser: Boolean
+) {
+ val canStopSharing = isLive && isOwnUser
+}
fun LocationShareItem.toMarkerData(): LocationMarkerData {
val pinVariant = if (assetType == AssetType.PIN) {
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
index 8bee410715..1c7b9a3160 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt
@@ -13,6 +13,7 @@ import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.toImmutableList
@@ -21,6 +22,8 @@ class ShowLocationStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aShowLocationState(),
+ aShowLocationState(isLive = true),
+ aShowLocationState(isLive = true, locationShares = emptyList()),
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
),
@@ -44,8 +47,10 @@ class ShowLocationStateProvider : PreviewParameterProvider {
private const val APP_NAME = "ApplicationName"
fun aShowLocationState(
+ isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
- locationShares: List = listOf(aLocationShareItem()),
+ locationShares: List = listOf(aLocationShareItem(isLive = isLive)),
+ focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
appName: String = APP_NAME,
@@ -54,26 +59,29 @@ fun aShowLocationState(
return ShowLocationState(
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
+ focusedLocation = focusedLocation,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,
+ isLive = isLive,
eventSink = eventSink,
)
}
fun aLocationShareItem(
userId: UserId = UserId("@alice:matrix.org"),
- displayName: String = "Alice",
+ displayName: String = USER_NAME_ALICE,
avatarData: AvatarData = AvatarData(
id = userId.value,
name = displayName,
url = null,
size = AvatarSize.UserListItem,
),
- formattedTimestamp: String = "Shared 1 min ago",
- location: Location = Location(1.23, 2.34, 4f),
isLive: Boolean = false,
assetType: AssetType? = null,
+ formattedTimestamp: String = "Shared 1 min ago",
+ location: Location = Location(1.23, 2.34, 4f),
+ isOwnUser: Boolean = false,
) = LocationShareItem(
userId = userId,
displayName = displayName,
@@ -82,4 +90,5 @@ fun aLocationShareItem(
location = location,
isLive = isLive,
assetType = assetType,
+ isOwnUser = isOwnUser,
)
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
index ad2d4cb8ca..6766fa6424 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt
@@ -12,8 +12,11 @@ package io.element.android.features.location.impl.show
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
@@ -21,11 +24,15 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
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
@@ -65,35 +72,33 @@ fun ShowLocationView(
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
)
- val initialPosition = remember {
- if (state.locationShares.isEmpty()) {
- MapDefaults.defaultCameraPosition
- } else {
- val firstLocation = state.locationShares.first().location
- CameraPosition(
- target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon),
+ val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
+ var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) }
+ LaunchedEffect(state.focusedLocation) {
+ if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) {
+ hasAnimatedToFocusedLocation = true
+ val position = CameraPosition(
+ target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM
)
+ cameraState.position = position
}
}
- val cameraState = rememberCameraState(firstPosition = initialPosition)
- val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
}
}
+ val userLocationState = rememberUserLocationState(state.hasLocationPermission)
val scaffoldState = rememberBottomSheetScaffoldState(
- bottomSheetState = rememberStandardBottomSheetState(
- initialValue =
- if (state.isSheetDraggable) {
- SheetValue.PartiallyExpanded
- } else {
- SheetValue.Expanded
- }
- )
+ bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
)
+ LaunchedEffect(state.isSheetDraggable) {
+ if (!state.isSheetDraggable) {
+ scaffoldState.bottomSheetState.expand()
+ }
+ }
MapBottomSheetScaffold(
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
@@ -116,29 +121,47 @@ fun ShowLocationView(
},
sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope()
- Spacer(Modifier.height(20.dp))
- Text(
- text = stringResource(CommonStrings.screen_static_location_sheet_title),
- style = ElementTheme.typography.fontBodyLgMedium,
- color = ElementTheme.colors.textPrimary,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- )
- state.locationShares.forEach { locationShare ->
- LocationShareRow(
- item = locationShare,
- onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
- modifier = Modifier.clickable {
- state.eventSink(ShowLocationEvent.TrackMyLocation(false))
- val position = CameraPosition(
- padding = sheetPaddings,
- target = Position(locationShare.location.lon, locationShare.location.lat),
- zoom = MapDefaults.DEFAULT_ZOOM
- )
- coroutineScope.launch {
- cameraState.animateTo(finalPosition = position)
- }
- }
+ if (!state.isSheetDraggable) {
+ // If sheet is draggable the DragHandle has already some padding
+ Spacer(Modifier.height(20.dp))
+ }
+ if (state.locationShares.isEmpty()) {
+ Text(
+ text = stringResource(CommonStrings.screen_live_location_sheet_nobody_sharing),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = ElementTheme.colors.textPrimary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(all = 16.dp),
+ textAlign = TextAlign.Center,
)
+ } else {
+ Text(
+ text = stringResource(CommonStrings.screen_static_location_sheet_title),
+ style = ElementTheme.typography.fontBodyLgMedium,
+ color = ElementTheme.colors.textPrimary,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ LazyColumn {
+ items(state.locationShares) { locationShare ->
+ LocationShareRow(
+ item = locationShare,
+ onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
+ onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
+ modifier = Modifier.clickable {
+ state.eventSink(ShowLocationEvent.TrackMyLocation(false))
+ val position = CameraPosition(
+ padding = sheetPaddings,
+ target = Position(locationShare.location.lon, locationShare.location.lat),
+ zoom = MapDefaults.DEFAULT_ZOOM
+ )
+ coroutineScope.launch {
+ cameraState.animateTo(finalPosition = position)
+ }
+ }
+ )
+ }
+ }
}
},
mapContent = {
diff --git a/features/location/impl/src/main/res/values-cs/translations.xml b/features/location/impl/src/main/res/values-cs/translations.xml
index 99deeba029..98af57abba 100644
--- a/features/location/impl/src/main/res/values-cs/translations.xml
+++ b/features/location/impl/src/main/res/values-cs/translations.xml
@@ -1,4 +1,6 @@
+ "Vaše historie aktuální polohy bude uložena v místnosti a bude viditelná pro členy i po skončení relace.""Zvolte, jak dlouho chcete sdílet svou aktuální polohu."
+ "Nemáte oprávnění sdílet svou aktuální polohu v této místnosti."
diff --git a/features/location/impl/src/main/res/values-da/translations.xml b/features/location/impl/src/main/res/values-da/translations.xml
index f15ab0fb2f..4d4c5d00bc 100644
--- a/features/location/impl/src/main/res/values-da/translations.xml
+++ b/features/location/impl/src/main/res/values-da/translations.xml
@@ -1,4 +1,5 @@
+ "Din live-positionshistorik gemmes i rummet og er synlig for medlemmerne, når sessionen er afsluttet.""Vælg, hvor længe du vil dele din aktuelle position."
diff --git a/features/location/impl/src/main/res/values-de/translations.xml b/features/location/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..1a1e208c3b
--- /dev/null
+++ b/features/location/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Wie lange soll der Live-Standort geteilt werden?"
+
diff --git a/features/location/impl/src/main/res/values-el/translations.xml b/features/location/impl/src/main/res/values-el/translations.xml
new file mode 100644
index 0000000000..8ae7c58c74
--- /dev/null
+++ b/features/location/impl/src/main/res/values-el/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Το ιστορικό ζωντανής τοποθεσίας σας θα αποθηκευτεί στην αίθουσα και θα είναι ορατό στα μέλη μετά το τέλος της συνεδρίας."
+ "Επιλέξτε για πόσο χρονικό διάστημα θα κοινοποιείτε την τρέχουσα τοποθεσία σας."
+
diff --git a/features/location/impl/src/main/res/values-et/translations.xml b/features/location/impl/src/main/res/values-et/translations.xml
new file mode 100644
index 0000000000..73847bcb43
--- /dev/null
+++ b/features/location/impl/src/main/res/values-et/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Sinu reaalajas jagatud asukoha ajalugu salvestub siin jututoas ja see on liikmetele nähtav ka pärast jagamissessiooni lõppu."
+ "Vali, kui kaua tahad oma reaalajas jagada."
+ "Sul pole õigust jagada selles jututoas oma asukohta reaalajas"
+
diff --git a/features/location/impl/src/main/res/values-fi/translations.xml b/features/location/impl/src/main/res/values-fi/translations.xml
index bc7e84e7b0..b35d11cd49 100644
--- a/features/location/impl/src/main/res/values-fi/translations.xml
+++ b/features/location/impl/src/main/res/values-fi/translations.xml
@@ -1,4 +1,6 @@
+ "Reaaliaikainen sijaintihistoriasi tallennetaan huoneeseen ja on jäsenten nähtävissä istunnon päätyttyä.""Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi."
+ "Sinulla ei ole oikeuksia jakaa reaaliaikaista sijaintiasi tässä huoneessa"
diff --git a/features/location/impl/src/main/res/values-hr/translations.xml b/features/location/impl/src/main/res/values-hr/translations.xml
new file mode 100644
index 0000000000..0a294ade1b
--- /dev/null
+++ b/features/location/impl/src/main/res/values-hr/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Vaša povijest lokacije uživo bit će pohranjena u sobi i vidljiva članovima nakon završetka sesije."
+ "Odaberite koliko dugo želite dijeliti svoju lokaciju uživo."
+
diff --git a/features/location/impl/src/main/res/values-hu/translations.xml b/features/location/impl/src/main/res/values-hu/translations.xml
index b89965485f..0ccb2a2413 100644
--- a/features/location/impl/src/main/res/values-hu/translations.xml
+++ b/features/location/impl/src/main/res/values-hu/translations.xml
@@ -2,4 +2,5 @@
"Az élő helymeghatározás története a szobában lesz tárolva, és a munkamenet befejezése után is látható marad a tagok számára.""Válassza ki, mennyi ideig szeretné megosztani az aktuális tartózkodási helyét."
+ "Nincs jogosultsága az élő tartózkodási helyének megosztására ebben a szobában."
diff --git a/features/location/impl/src/main/res/values-it/translations.xml b/features/location/impl/src/main/res/values-it/translations.xml
index 235a9eba4a..44adc1e3c5 100644
--- a/features/location/impl/src/main/res/values-it/translations.xml
+++ b/features/location/impl/src/main/res/values-it/translations.xml
@@ -2,4 +2,5 @@
"La cronologia delle tue posizioni in tempo reale verrà archiviata nella stanza e sarà visibile ai membri al termine della sessione.""Scegli per quanto tempo condividere la tua posizione in tempo reale."
+ "Non hai l\'autorizzazione per condividere la tua posizione in tempo reale in questa stanza"
diff --git a/features/location/impl/src/main/res/values-ja/translations.xml b/features/location/impl/src/main/res/values-ja/translations.xml
index 971693ef11..1060120ee5 100644
--- a/features/location/impl/src/main/res/values-ja/translations.xml
+++ b/features/location/impl/src/main/res/values-ja/translations.xml
@@ -2,4 +2,5 @@
"ライブ位置情報の履歴はルームに保管され、メンバーは後から確認することもできます。""ライブ位置情報を共有する期間を選択してください。"
+ "このルームでライブ位置情報を共有する権限がありません。"
diff --git a/features/location/impl/src/main/res/values-pl/translations.xml b/features/location/impl/src/main/res/values-pl/translations.xml
new file mode 100644
index 0000000000..c480d0f43b
--- /dev/null
+++ b/features/location/impl/src/main/res/values-pl/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Twoja historia lokalizacji na żywo zostanie zapisana w pokoju i będzie widoczna dla członków po zakończeniu sesji."
+ "Wybierz, jak długo chcesz udostępniać swoją lokalizację na żywo."
+ "Nie masz uprawnień do udostępniania swojej lokalizacji na żywo w tym pokoju"
+
diff --git a/features/location/impl/src/main/res/values-ro/translations.xml b/features/location/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..85e665647a
--- /dev/null
+++ b/features/location/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Istoricul locațiilor dumneavoastră va fi stocat în cameră și va fi vizibil pentru membri după încheierea sesiunii."
+ "Alegeți cât timp doriți să vă partajați locația în timp real."
+ "Nu aveți permisiunea de a vă partaja locația în această cameră."
+
diff --git a/features/location/impl/src/main/res/values-ru/translations.xml b/features/location/impl/src/main/res/values-ru/translations.xml
index 3c496f1d39..97b279621c 100644
--- a/features/location/impl/src/main/res/values-ru/translations.xml
+++ b/features/location/impl/src/main/res/values-ru/translations.xml
@@ -2,4 +2,5 @@
"История вашего местоположения в режиме реального времени будет сохранена в комнате и станет доступна участникам после окончания сессии.""Выберите, как долго вы будете делиться своим местоположением в режиме реального времени."
+ "У тебя нет прав на то, чтобы делиться своим текущим местоположением в этой комнате"
diff --git a/features/location/impl/src/main/res/values-uk/translations.xml b/features/location/impl/src/main/res/values-uk/translations.xml
new file mode 100644
index 0000000000..3c8155817a
--- /dev/null
+++ b/features/location/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Ваша історія поточного місцезнаходження зберігатиметься у кімнаті та буде доступна учасникам після завершення сеансу."
+ "Виберіть, як довго ділитися своїм місцезнаходженням."
+
diff --git a/features/location/impl/src/main/res/values-uz/translations.xml b/features/location/impl/src/main/res/values-uz/translations.xml
new file mode 100644
index 0000000000..2b10808d85
--- /dev/null
+++ b/features/location/impl/src/main/res/values-uz/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "Jonli joylashuv tarixingiz chat-xonada saqlanadi va sessiya tugaganidan keyin homiylarga ko‘rinadi."
+ "Jonli joylashuvingiz qancha vaqt ulashilishini tanlang."
+
diff --git a/features/location/impl/src/main/res/values-zh-rTW/translations.xml b/features/location/impl/src/main/res/values-zh-rTW/translations.xml
new file mode 100644
index 0000000000..27f507732d
--- /dev/null
+++ b/features/location/impl/src/main/res/values-zh-rTW/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"
+ "選擇分享即時位置的時間長度。"
+
diff --git a/features/location/impl/src/main/res/values-zh/translations.xml b/features/location/impl/src/main/res/values-zh/translations.xml
new file mode 100644
index 0000000000..86f2f2a9da
--- /dev/null
+++ b/features/location/impl/src/main/res/values-zh/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "你的实时位置历史将存储在房间中,并于会话结束后对其他成员可见。"
+ "选择共享实时位置的时长。"
+ "你无权在此房内共享实时位置。"
+
diff --git a/features/location/impl/src/main/res/values/localazy.xml b/features/location/impl/src/main/res/values/localazy.xml
index ac2ff4b2a0..975bb3c6ea 100644
--- a/features/location/impl/src/main/res/values/localazy.xml
+++ b/features/location/impl/src/main/res/values/localazy.xml
@@ -2,4 +2,5 @@
"Your live location history will be stored in the room and visible to members after the session ends.""Choose how long to share your live location."
+ "You do not have permissions to share your live location in this room"
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt
index c8e1f21a48..debe95b464 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt
@@ -21,7 +21,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
- val result = checkLocationConstraints(permissionsState, locationActions)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
}
@@ -33,7 +33,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
- val result = checkLocationConstraints(permissionsState, locationActions)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.Success)
}
@@ -45,7 +45,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = false)
- val result = checkLocationConstraints(permissionsState, locationActions)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled)
}
@@ -58,7 +58,7 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
- val result = checkLocationConstraints(permissionsState, locationActions)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
}
@@ -71,8 +71,20 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
- val result = checkLocationConstraints(permissionsState, locationActions)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
}
+
+ @Test
+ fun `checkLocationConstraints returns NotEnoughPowerLevel when send permissions are not granted`() {
+ val permissionsState = aPermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ )
+ val locationActions = FakeLocationActions(isLocationEnabled = true)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.DEFAULT)
+
+ assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel)
+ }
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt
new file mode 100644
index 0000000000..85f0e1c33f
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManagerTest.kt
@@ -0,0 +1,488 @@
+/*
+ * 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.location.impl.live
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.emptyPreferences
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
+import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID_2
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
+import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Instant
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DefaultActiveLiveLocationShareManagerTest {
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `starting the first share starts the coordinator service after the beacon echo and adds an active share`() = runTest {
+ val startServiceRecorder = lambdaRecorder { }
+ val stopServiceRecorder = lambdaRecorder { }
+ val coordinator = createCoordinator(
+ startService = startServiceRecorder,
+ stopService = stopServiceRecorder
+ )
+ val beaconInfoUpdates = MutableSharedFlow(replay = 1)
+ val room = FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ )
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdates,
+ ).apply { givenGetRoomResult(A_ROOM_ID, room) },
+ coordinator = coordinator,
+ clock = FakeSystemClock(epochMillisResult = 123L),
+ )
+ advanceUntilIdle()
+
+ val result = async { manager.startShare(A_ROOM_ID, 60.minutes) }
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+
+ assertThat(result.await().isSuccess).isTrue()
+ assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
+ assert(startServiceRecorder).isCalledOnce()
+ assert(stopServiceRecorder).isNeverCalled()
+ }
+
+ @Test
+ fun `stopping the last share stops the coordinator service`() = runTest {
+ val startServiceRecorder = lambdaRecorder { }
+ val stopServiceRecorder = lambdaRecorder { }
+ val coordinator = createCoordinator(
+ startService = startServiceRecorder,
+ stopService = stopServiceRecorder
+ )
+ val beaconInfoUpdates = MutableSharedFlow(replay = 1)
+ val room = FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ )
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdates,
+ ).apply { givenGetRoomResult(A_ROOM_ID, room) },
+ coordinator = coordinator,
+ )
+ advanceUntilIdle()
+
+ val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+ assertThat(startResult.await().isSuccess).isTrue()
+
+ val result = manager.stopShare(A_ROOM_ID)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(manager.sharingRoomIds.value).isEmpty()
+ assert(startServiceRecorder).isCalledOnce()
+ assert(stopServiceRecorder).isCalledOnce()
+ }
+
+ @Test
+ fun `two managers with the same room id keep isolated state per session`() = runTest {
+ val coordinator = createCoordinator()
+ val beaconInfoUpdatesOne = MutableSharedFlow(replay = 1)
+ val beaconInfoUpdatesTwo = MutableSharedFlow(replay = 1)
+ val managerOne = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdatesOne,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ ),
+ )
+ },
+ coordinator = coordinator,
+ )
+ val managerTwo = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID_2,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdatesTwo,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ ),
+ )
+ },
+ coordinator = coordinator,
+ )
+ advanceUntilIdle()
+
+ val startResult = async { managerOne.startShare(A_ROOM_ID, 15.minutes) }
+ beaconInfoUpdatesOne.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+ assertThat(startResult.await().isSuccess).isTrue()
+
+ assertThat(managerOne.sharingRoomIds.value).containsExactly(A_ROOM_ID)
+ assertThat(managerTwo.sharingRoomIds.value).isEmpty()
+ }
+
+ @Test
+ fun `start share persists room expiry after beacon echo`() = runTest {
+ val liveLocationStore = createLiveLocationStore()
+ val coordinator = createCoordinator()
+ val beaconInfoUpdates = MutableSharedFlow(replay = 1)
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdates,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ ),
+ )
+ },
+ coordinator = coordinator,
+ liveLocationStore = liveLocationStore,
+ clock = FakeSystemClock(epochMillisResult = 123L),
+ )
+ advanceUntilIdle()
+
+ val result = async { manager.startShare(A_ROOM_ID, 15.minutes) }
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+
+ assertThat(result.await().isSuccess).isTrue()
+ assertThat(liveLocationStore.getLiveLocationExpiries()).containsKey(A_ROOM_ID)
+ }
+
+ @Test
+ fun `stop share removes persisted expiry`() = runTest {
+ val liveLocationStore = createLiveLocationStore()
+ val coordinator = createCoordinator()
+ val beaconInfoUpdates = MutableSharedFlow(replay = 1)
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdates,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ ),
+ )
+ },
+ coordinator = coordinator,
+ liveLocationStore = liveLocationStore,
+ )
+ advanceUntilIdle()
+
+ val startResult = async { manager.startShare(A_ROOM_ID, 15.minutes) }
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+ assertThat(startResult.await().isSuccess).isTrue()
+
+ manager.stopShare(A_ROOM_ID)
+
+ assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
+ }
+
+ @Test
+ fun `setup restores unexpired stored share and registers coordinator`() = runTest {
+ val startServiceRecorder = lambdaRecorder { }
+ val stopServiceRecorder = lambdaRecorder { }
+ val liveLocationStore = createLiveLocationStore().apply {
+ setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
+ }
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ).apply {
+ givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom())
+ },
+ coordinator = createCoordinator(
+ startService = startServiceRecorder,
+ stopService = stopServiceRecorder,
+ ),
+ liveLocationStore = liveLocationStore,
+ clock = FakeSystemClock(epochMillisResult = 1_000L),
+ )
+
+ assertThat(manager.sharingRoomIds.value).containsExactly(A_ROOM_ID)
+ assert(startServiceRecorder).isCalledOnce()
+ assert(stopServiceRecorder).isNeverCalled()
+ }
+
+ @Test
+ fun `setup remotely stops expired stored share and removes it from store`() = runTest {
+ val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) }
+ val liveLocationStore = createLiveLocationStore().apply {
+ setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
+ }
+ createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult),
+ )
+ },
+ coordinator = createCoordinator(),
+ liveLocationStore = liveLocationStore,
+ clock = FakeSystemClock(epochMillisResult = 5_000L),
+ )
+ advanceUntilIdle()
+ assert(stopLiveLocationShareResult).isCalledOnce()
+ assertThat(liveLocationStore.getLiveLocationExpiries()).isEmpty()
+ }
+
+ @Test
+ fun `stop share closes loaded room and removes persisted expiry when room is not tracked`() = runTest {
+ val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) }
+ val room = FakeJoinedRoom(stopLiveLocationShareResult = stopLiveLocationShareResult)
+ val liveLocationStore = createInMemoryLiveLocationStore()
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ).apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ },
+ coordinator = createCoordinator(),
+ liveLocationStore = liveLocationStore,
+ )
+ liveLocationStore.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(10_000L))
+
+ val result = manager.stopShare(A_ROOM_ID)
+
+ assertThat(result.isSuccess).isTrue()
+ assert(stopLiveLocationShareResult).isCalledOnce()
+ assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
+ room.baseRoom.assertDestroyed()
+ }
+
+ @Test
+ fun `share is automatically stopped when timeout elapses`() = runTest {
+ val liveLocationStore = createInMemoryLiveLocationStore()
+ val beaconInfoUpdates = MutableSharedFlow(replay = 1)
+ val stopLiveLocationShareResult = lambdaRecorder> { Result.success(Unit) }
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdates,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = stopLiveLocationShareResult
+ ),
+ )
+ },
+ coordinator = createCoordinator(),
+ liveLocationStore = liveLocationStore,
+ clock = FakeSystemClock(epochMillisResult = 123L),
+ )
+ advanceUntilIdle()
+
+ val startResult = async { manager.startShare(A_ROOM_ID, 1.minutes) }
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+ assertThat(startResult.await().isSuccess).isTrue()
+
+ manager.sharingRoomIds.test {
+ assertThat(awaitItem()).containsExactly(A_ROOM_ID)
+ assertThat(awaitItem()).isEmpty()
+ advanceUntilIdle()
+ assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
+ assert(stopLiveLocationShareResult).isCalledExactly(2)
+ }
+ }
+
+ @Test
+ fun `restored share is automatically stopped when remaining timeout elapses`() = runTest {
+ val liveLocationStore = createInMemoryLiveLocationStore().apply {
+ setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(6_000L))
+ }
+ val stopLiveLocationShareLambda = lambdaRecorder> { Result.success(Unit) }
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ stopLiveLocationShareResult = stopLiveLocationShareLambda
+ ),
+ )
+ },
+ coordinator = createCoordinator(),
+ liveLocationStore = liveLocationStore,
+ clock = FakeSystemClock(epochMillisResult = 1_000L),
+ )
+
+ manager.sharingRoomIds.test {
+ assertThat(awaitItem()).containsExactly(A_ROOM_ID)
+ assertThat(awaitItem()).isEmpty()
+ advanceUntilIdle()
+ assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
+ assert(stopLiveLocationShareLambda).isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `session deleted clears local state`() = runTest {
+ val startServiceRecorder = lambdaRecorder { }
+ val stopServiceRecorder = lambdaRecorder { }
+ val liveLocationStore = createInMemoryLiveLocationStore()
+ val sessionObserver = FakeSessionObserver()
+ val beaconInfoUpdates = MutableSharedFlow(replay = 1)
+ val manager = createManager(
+ client = FakeMatrixClient(
+ sessionId = A_SESSION_ID,
+ sessionCoroutineScope = backgroundScope,
+ ownBeaconInfoUpdates = beaconInfoUpdates,
+ ).apply {
+ givenGetRoomResult(
+ A_ROOM_ID,
+ FakeJoinedRoom(
+ startLiveLocationShareResult = { Result.success(AN_EVENT_ID) },
+ stopLiveLocationShareResult = { Result.success(Unit) },
+ ),
+ )
+ },
+ coordinator = createCoordinator(
+ startService = startServiceRecorder,
+ stopService = stopServiceRecorder,
+ ),
+ liveLocationStore = liveLocationStore,
+ sessionObserver = sessionObserver,
+ )
+ advanceUntilIdle()
+
+ val firstStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+ assertThat(firstStart.await().isSuccess).isTrue()
+
+ sessionObserver.onSessionDeleted(A_SESSION_ID.value)
+ advanceUntilIdle()
+
+ assertThat(manager.sharingRoomIds.value).isEmpty()
+ assertThat(liveLocationStore.getLiveLocationExpiries()).doesNotContainKey(A_ROOM_ID)
+ assert(startServiceRecorder).isCalledOnce()
+ assert(stopServiceRecorder).isCalledOnce()
+
+ val secondStart = async { manager.startShare(A_ROOM_ID, 15.minutes) }
+ advanceUntilIdle()
+ assertThat(secondStart.isCompleted).isFalse()
+
+ beaconInfoUpdates.emit(BeaconInfoUpdate(roomId = A_ROOM_ID, beaconId = AN_EVENT_ID, isLive = true))
+ assertThat(secondStart.await().isSuccess).isTrue()
+ }
+
+ private suspend fun createManager(
+ client: FakeMatrixClient = FakeMatrixClient(sessionId = A_SESSION_ID),
+ coordinator: LiveLocationSharingCoordinator = createCoordinator(),
+ liveLocationStore: LiveLocationStore = createLiveLocationStore(),
+ clock: SystemClock = FakeSystemClock(),
+ sessionObserver: SessionObserver = FakeSessionObserver(),
+ ): DefaultActiveLiveLocationShareManager {
+ return DefaultActiveLiveLocationShareManager(
+ matrixClient = client,
+ coordinator = coordinator,
+ liveLocationStore = liveLocationStore,
+ clock = clock,
+ sessionObserver = sessionObserver,
+ ).apply {
+ setup()
+ }
+ }
+
+ private fun createCoordinator(
+ startService: () -> Unit = {},
+ stopService: () -> Unit = {},
+ nowMillis: () -> Long = { 0L },
+ ): LiveLocationSharingCoordinator {
+ return LiveLocationSharingCoordinator(
+ startService = startService,
+ stopService = stopService,
+ nowMillis = nowMillis,
+ )
+ }
+
+ private fun createLiveLocationStore(
+ sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
+ preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
+ ): LiveLocationStore {
+ return LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = sessionId,
+ )
+ }
+
+ private fun createInMemoryLiveLocationStore(
+ sessionId: io.element.android.libraries.matrix.api.core.SessionId = A_SESSION_ID,
+ ): LiveLocationStore {
+ val preferenceDataStoreFactory = object : PreferenceDataStoreFactory {
+ override fun create(name: String): DataStore {
+ var preferences: Preferences = emptyPreferences()
+ return object : DataStore {
+ override val data: Flow
+ get() = flowOf(preferences)
+
+ override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
+ preferences = transform(preferences)
+ return preferences
+ }
+ }
+ }
+ }
+ return createLiveLocationStore(
+ sessionId = sessionId,
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ )
+ }
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt
new file mode 100644
index 0000000000..f74322b4c5
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.location.impl.live
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.location.api.Location
+import io.element.android.features.location.impl.live.service.LiveLocationReceiver
+import io.element.android.features.location.impl.live.service.LiveLocationSharingCoordinator
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID_2
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LiveLocationSharingCoordinatorTest {
+ @Test
+ fun `first registration starts the service and last unregister stops it`() = runTest {
+ var startCount = 0
+ var stopCount = 0
+ val coordinator = LiveLocationSharingCoordinator(
+ startService = { startCount++ },
+ stopService = { stopCount++ },
+ nowMillis = { 0L },
+ )
+
+ coordinator.register(A_SESSION_ID, LiveLocationReceiver { })
+ coordinator.unregister(A_SESSION_ID)
+
+ assertThat(startCount).isEqualTo(1)
+ assertThat(stopCount).isEqualTo(1)
+ }
+
+ @Test
+ fun `dispatch isolates receiver failures and still reaches later receivers`() = runTest {
+ val delivered = mutableListOf()
+ val coordinator = LiveLocationSharingCoordinator(
+ startService = { },
+ stopService = { },
+ nowMillis = { 4_000L },
+ )
+
+ coordinator.register(A_SESSION_ID) { error("boom") }
+ coordinator.register(A_SESSION_ID_2) { location -> delivered += location }
+ coordinator.dispatch(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
+
+ assertThat(delivered).containsExactly(Location(lat = 1.0, lon = 2.0, accuracy = 3f))
+ }
+
+ @Test
+ fun `dispatch delivers first location immediately`() = runTest {
+ var nowMillis = 4_000L
+ val delivered = mutableListOf()
+ val coordinator = LiveLocationSharingCoordinator(
+ startService = { },
+ stopService = { },
+ nowMillis = { nowMillis },
+ )
+
+ coordinator.register(A_SESSION_ID) { location -> delivered += location }
+
+ val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
+
+ coordinator.dispatch(firstLocation)
+
+ assertThat(delivered).containsExactly(firstLocation)
+ }
+
+ @Test
+ fun `dispatch drops updates inside the throttle window`() = runTest {
+ var nowMillis = 4_000L
+ val delivered = mutableListOf()
+ val coordinator = LiveLocationSharingCoordinator(
+ startService = { },
+ stopService = { },
+ nowMillis = { nowMillis },
+ )
+
+ coordinator.register(A_SESSION_ID) { location -> delivered += location }
+
+ val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
+ val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
+
+ coordinator.dispatch(firstLocation)
+ nowMillis += 500
+ coordinator.dispatch(secondLocation)
+
+ assertThat(delivered).containsExactly(firstLocation)
+ }
+
+ @Test
+ fun `dispatch delivers next update after the throttle window elapses`() = runTest {
+ var nowMillis = 4_000L
+ val delivered = mutableListOf()
+ val coordinator = LiveLocationSharingCoordinator(
+ startService = { },
+ stopService = { },
+ nowMillis = { nowMillis },
+ )
+
+ coordinator.register(A_SESSION_ID) { location -> delivered += location }
+
+ val firstLocation = Location(lat = 1.0, lon = 2.0, accuracy = 3f)
+ val secondLocation = Location(lat = 4.0, lon = 5.0, accuracy = 6f)
+
+ coordinator.dispatch(firstLocation)
+ nowMillis += 3_000
+ coordinator.dispatch(secondLocation)
+
+ assertThat(delivered).containsExactly(firstLocation, secondLocation).inOrder()
+ }
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt
index edd000e02c..6f20e296d9 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt
@@ -13,15 +13,18 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
+import io.element.android.features.location.impl.live.LiveLocationStore
+import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
+import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -30,24 +33,29 @@ class DefaultShareLocationEntryPointTest {
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
- fun `test node builder`() {
+ fun `test node builder`() = runTest {
val entryPoint = DefaultShareLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
+ val room = FakeJoinedRoom()
ShareLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode ->
ShareLocationPresenter(
permissionsPresenterFactory = { FakePermissionsPresenter() },
- room = FakeJoinedRoom(),
+ room = room,
timelineMode = timelineMode,
analyticsService = FakeAnalyticsService(),
messageComposerContext = FakeMessageComposerContext(),
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
- featureFlagService = FakeFeatureFlagService(),
client = FakeMatrixClient(),
durationFormatter = FakeDurationFormatter(),
+ liveLocationShareManager = FakeActiveLiveLocationShareManager(),
+ liveLocationStore = LiveLocationStore(
+ preferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
+ sessionId = room.sessionId,
+ ),
)
},
analyticsService = FakeAnalyticsService(),
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt
index 46bc4a55df..1bc4fce586 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt
@@ -10,6 +10,9 @@
package io.element.android.features.location.impl.share
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.emptyPreferences
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
@@ -22,28 +25,46 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.features.location.impl.live.LiveLocationStore
+import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
-import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
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.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
+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.powerlevels.FakeRoomPermissions
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
+import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.assert
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.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
class ShareLocationPresenterTest {
@get:Rule
@@ -54,25 +75,28 @@ class ShareLocationPresenterTest {
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
- private val fakeFeatureFlagService = FakeFeatureFlagService()
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val durationFormatter = FakeDurationFormatter()
- private fun createShareLocationPresenter(
+ private fun TestScope.createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(),
+ timelineMode: Timeline.Mode = Timeline.Mode.Live,
locationActions: FakeLocationActions = fakeLocationActions,
+ liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
+ liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
- timelineMode = Timeline.Mode.Live,
+ timelineMode = timelineMode,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
locationActions = locationActions,
buildMeta = fakeBuildMeta,
- featureFlagService = fakeFeatureFlagService,
client = fakeMatrixClient,
durationFormatter = durationFormatter,
+ liveLocationShareManager = liveLocationShareManager,
+ liveLocationStore = liveLocationStore,
)
@Test
@@ -296,7 +320,15 @@ class ShareLocationPresenterTest {
@Test
fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest {
- val shareLocationPresenter = createShareLocationPresenter()
+ val joinedRoom = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = grantedSendLiveLocationPermissions()
+ )
+ )
+ val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
+ setAcceptedLiveLocationDisclaimer().getOrThrow()
+ }
+ val shareLocationPresenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
@@ -307,7 +339,7 @@ class ShareLocationPresenterTest {
shareLocationPresenter.test {
skipItems(1)
val initialState = awaitItem()
- initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
+ initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val durationDialogState = awaitItem()
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
@@ -315,9 +347,155 @@ class ShareLocationPresenterTest {
}
}
+ @Test
+ fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest {
+ val presenter = createShareLocationPresenter()
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+
+ state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
+ val dialogState = awaitItem()
+
+ assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
+ }
+ }
+
+ @Test
+ fun `AcceptLiveLocationDisclaimer persists acceptance and shows durations`() = runTest {
+ val joinedRoom = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = grantedSendLiveLocationPermissions()
+ )
+ )
+ val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId)
+ val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
+ awaitItem()
+
+ state.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
+ val durationState = awaitItem()
+
+ assertThat(locationStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
+ assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
+ }
+ }
+
+ @Test
+ fun `AcceptLiveLocationDisclaimer keeps disclaimer gate active when persistence fails`() = runTest {
+ val joinedRoom = FakeJoinedRoom()
+ val presenter = createShareLocationPresenter(
+ joinedRoom = joinedRoom,
+ liveLocationStore = createFailingLiveLocationStore(sessionId = joinedRoom.sessionId),
+ )
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
+ val disclaimerState = awaitItem()
+
+ disclaimerState.eventSink(ShareLocationEvent.AcceptLiveLocationDisclaimer)
+ advanceUntilIdle()
+
+ expectNoEvents()
+ }
+ }
+
+ @Test
+ fun `ShowLiveLocationDurationPicker bypasses disclaimer when already accepted`() = runTest {
+ val joinedRoom = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = grantedSendLiveLocationPermissions()
+ )
+ )
+ val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
+ setAcceptedLiveLocationDisclaimer().getOrThrow()
+ }
+ val presenter = createShareLocationPresenter(joinedRoom = joinedRoom, liveLocationStore = locationStore)
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+
+ state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
+ val durationState = awaitItem()
+
+ assertThat(durationState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
+ }
+ }
+
+ @Test
+ fun `ShowLiveLocationDurationPicker uses the active session disclaimer state`() = runTest {
+ val joinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = SessionId("@alice:server")))
+ createLiveLocationStore(sessionId = SessionId("@bob:server"))
+ .setAcceptedLiveLocationDisclaimer()
+ .getOrThrow()
+ val presenter = createShareLocationPresenter(
+ joinedRoom = joinedRoom,
+ liveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
+ )
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+
+ state.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
+ val dialogState = awaitItem()
+
+ assertThat(dialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDisclaimer)
+ }
+ }
+
@Test
fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
- val shareLocationPresenter = createShareLocationPresenter()
+ val joinedRoom = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = grantedSendLiveLocationPermissions()
+ )
+ )
+ val locationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId).apply {
+ setAcceptedLiveLocationDisclaimer().getOrThrow()
+ }
+ val shareLocationPresenter = createShareLocationPresenter(
+ joinedRoom = joinedRoom,
+ liveLocationStore = locationStore,
+ )
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
@@ -332,7 +510,7 @@ class ShareLocationPresenterTest {
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
- dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
+ dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val constraintDialogState = awaitItem()
assertThat(constraintDialogState.dialogState).isEqualTo(
@@ -447,4 +625,86 @@ class ShareLocationPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
+
+ @Test
+ fun `StartLiveLocationShare event calls manager startShare`() = runTest {
+ val startShareLambda = lambdaRecorder { _: RoomId, _: Duration -> Result.success(Unit) }
+ val manager = FakeActiveLiveLocationShareManager(
+ startShareLambda = startShareLambda,
+ )
+ val shareLocationPresenter = createShareLocationPresenter(liveLocationShareManager = manager)
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.AllGranted,
+ shouldShowRationale = false,
+ )
+ )
+
+ shareLocationPresenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration = 1.hours))
+ advanceUntilIdle()
+ assert(startShareLambda).isCalledOnce().with(
+ value(A_ROOM_ID),
+ value(1.hours)
+ )
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `canShareLiveLocation is true in live timeline`() = runTest {
+ val shareLocationPresenter = createShareLocationPresenter(
+ timelineMode = Timeline.Mode.Live,
+ )
+ shareLocationPresenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ assertThat(state.canShareLiveLocation).isTrue()
+ }
+ }
+
+ @Test
+ fun `canShareLiveLocation is false in thread timeline`() = runTest {
+ val shareLocationPresenter = createShareLocationPresenter(
+ timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
+ )
+ shareLocationPresenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ assertThat(state.canShareLiveLocation).isFalse()
+ }
+ }
}
+
+private fun createLiveLocationStore(
+ sessionId: SessionId = A_SESSION_ID,
+ preferenceDataStoreFactory: PreferenceDataStoreFactory = FakePreferenceDataStoreFactory(),
+): LiveLocationStore {
+ return LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = sessionId,
+ )
+}
+
+private fun createFailingLiveLocationStore(sessionId: SessionId = A_SESSION_ID): LiveLocationStore {
+ val failingPreferenceDataStoreFactory = object : PreferenceDataStoreFactory {
+ override fun create(name: String): DataStore = object : DataStore {
+ override val data: Flow = flowOf(emptyPreferences())
+
+ override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
+ error("Failed to update preferences")
+ }
+ }
+ }
+ return createLiveLocationStore(
+ sessionId = sessionId,
+ preferenceDataStoreFactory = failingPreferenceDataStoreFactory,
+ )
+}
+
+private fun grantedSendLiveLocationPermissions(): FakeRoomPermissions = FakeRoomPermissions(
+ canSendState = { it is StateEventType.BeaconInfo },
+ canSendMessage = { it is MessageEventType.Beacon }
+)
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt
index 317fbf8fed..370ccac8ab 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt
@@ -5,15 +5,18 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.location.impl.share
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags
@@ -23,102 +26,98 @@ 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 org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShareLocationViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `test back action`() {
+ fun `test back action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setShareLocationView(
+ setShareLocationView(
state = aShareLocationState(
eventSink = eventsRecorder
),
navigateUp = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `test fab click`() {
+ fun `test fab click`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
- rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
+ onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation)
}
@Test
- fun `when permission denied is displayed user can open the settings`() {
+ fun `when permission denied is displayed user can open the settings`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings)
}
@Test
- fun `when permission denied is displayed user can close the dialog`() {
+ fun `when permission denied is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
- fun `when permission rationale is displayed user can request permissions`() {
+ fun `when permission rationale is displayed user can request permissions`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions)
}
@Test
- fun `when permission rationale is displayed user can close the dialog`() {
+ fun `when permission rationale is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder
),
navigateUp = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
@Test
- fun `when location service disabled is displayed user can open location settings`() {
+ fun `when location service disabled is displayed user can open location settings`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
@@ -126,14 +125,14 @@ class ShareLocationViewTest {
),
navigateUp = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings)
}
@Test
- fun `when location service disabled is displayed user can close the dialog`() {
+ fun `when location service disabled is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShareLocationView(
+ setShareLocationView(
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true,
@@ -141,12 +140,44 @@ class ShareLocationViewTest {
),
navigateUp = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
+ eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
+ }
+
+ @Test
+ fun `when disclaimer is displayed user can accept`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder()
+ setShareLocationView(
+ aShareLocationState(
+ dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
+ eventSink = eventsRecorder,
+ canShareLiveLocation = true,
+ ),
+ navigateUp = EnsureNeverCalled(),
+ )
+
+ clickOn(CommonStrings.action_accept)
+ eventsRecorder.assertSingle(ShareLocationEvent.AcceptLiveLocationDisclaimer)
+ }
+
+ @Test
+ fun `when disclaimer is displayed user can decline`() = runAndroidComposeUiTest {
+ val eventsRecorder = EventsRecorder()
+ setShareLocationView(
+ aShareLocationState(
+ dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
+ eventSink = eventsRecorder,
+ canShareLiveLocation = true,
+ ),
+ navigateUp = EnsureNeverCalled(),
+ )
+
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
}
}
-private fun AndroidComposeTestRule.setShareLocationView(
+private fun AndroidComposeUiTest.setShareLocationView(
state: ShareLocationState,
navigateUp: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt
index 451531fc7e..985dcc1f9c 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt
@@ -16,9 +16,11 @@ import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
+import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.node.TestParentNode
@@ -33,6 +35,7 @@ class DefaultShowLocationEntryPointTest {
fun `test node builder`() {
val entryPoint = DefaultShowLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
+ val joinedRoom = FakeJoinedRoom()
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
@@ -43,7 +46,9 @@ class DefaultShowLocationEntryPointTest {
locationActions = FakeLocationActions(),
buildMeta = aBuildMeta(),
dateFormatter = FakeDateFormatter(),
- stringProvider = FakeStringProvider()
+ stringProvider = FakeStringProvider(),
+ joinedRoom = joinedRoom,
+ liveLocationShareManager = FakeActiveLiveLocationShareManager(),
)
},
analyticsService = FakeAnalyticsService(),
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt
new file mode 100644
index 0000000000..0b8e04abf8
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/LiveLocationShareComparatorTest.kt
@@ -0,0 +1,57 @@
+/*
+ * 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.location.impl.show
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
+import org.junit.Test
+
+class LiveLocationShareComparatorTest {
+ private val currentUser = UserId("@me:matrix.org")
+ private val comparator = LiveLocationShareComparator(currentUser)
+
+ @Test
+ fun `compare returns zero when comparing the same current user share`() {
+ val share = aLiveLocationShare(userId = currentUser, startTimestamp = 123L)
+
+ val result = comparator.compare(share, share)
+
+ assertThat(result).isEqualTo(0)
+ }
+
+ @Test
+ fun `compare orders current user share before another user share`() {
+ val otherShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L)
+ val currentUserShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L)
+
+ val sortedShares = listOf(otherShare, currentUserShare).sortedWith(comparator)
+
+ assertThat(sortedShares).containsExactly(currentUserShare, otherShare).inOrder()
+ }
+
+ @Test
+ fun `compare orders current user shares by newest start timestamp first`() {
+ val newerShare = aLiveLocationShare(userId = currentUser, startTimestamp = 200L)
+ val olderShare = aLiveLocationShare(userId = currentUser, startTimestamp = 100L)
+
+ val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator)
+
+ assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
+ }
+
+ @Test
+ fun `compare orders non current user shares by newest start timestamp first`() {
+ val newerShare = aLiveLocationShare(userId = UserId("@alice:matrix.org"), startTimestamp = 200L)
+ val olderShare = aLiveLocationShare(userId = UserId("@bob:matrix.org"), startTimestamp = 100L)
+
+ val sortedShares = listOf(olderShare, newerShare).sortedWith(comparator)
+
+ assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
+ }
+}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
index 931dd55cea..c1f9f6487e 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt
@@ -20,17 +20,26 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
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.location.AssetType
+import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
+import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
+@OptIn(ExperimentalCoroutinesApi::class)
class ShowLocationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -51,13 +60,17 @@ class ShowLocationPresenterTest {
assetType = null,
),
locationActions: FakeLocationActions = fakeLocationActions,
+ joinedRoom: JoinedRoom = FakeJoinedRoom(),
+ liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
locationActions = locationActions,
buildMeta = fakeBuildMeta,
dateFormatter = fakeDateFormatter,
- stringProvider = FakeStringProvider()
+ stringProvider = FakeStringProvider(),
+ joinedRoom = joinedRoom,
+ liveLocationShareManager = liveLocationShareManager,
)
@Test
@@ -195,7 +208,7 @@ class ShowLocationPresenterTest {
)
)
val presenter = createShowLocationPresenter()
- presenter.test {
+ presenter.test {
// Skip initial state
val initialState = awaitItem()
@@ -318,4 +331,139 @@ class ShowLocationPresenterTest {
assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1)
}
}
+
+ @Test
+ fun `live mode emits empty location shares initially`() = runTest {
+ val presenter = createShowLocationPresenter(
+ mode = ShowLocationMode.Live(senderId = UserId("@alice:matrix.org")),
+ joinedRoom = FakeJoinedRoom(),
+ )
+ presenter.test {
+ val initialState = awaitItem()
+ assertThat(initialState.locationShares).isEmpty()
+ assertThat(initialState.isSheetDraggable).isFalse()
+ }
+ }
+
+ @Test
+ fun `live mode collects live shares from room`() = runTest {
+ val userId = UserId("@bob:matrix.org")
+ val liveSharesFlow = MutableStateFlow(
+ listOf(
+ aLiveLocationShare(userId = userId)
+ )
+ )
+ val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
+
+ val presenter = createShowLocationPresenter(
+ mode = ShowLocationMode.Live(senderId = userId),
+ joinedRoom = fakeRoom,
+ )
+ presenter.test {
+ // Skip initial empty state from collectAsState(initial = emptyList())
+ skipItems(1)
+ val state = awaitItem()
+
+ assertThat(state.locationShares).hasSize(1)
+ val item = state.locationShares.first()
+ assertThat(item.userId).isEqualTo(userId)
+ assertThat(item.location.lat).isEqualTo(48.8584)
+ assertThat(item.location.lon).isEqualTo(2.2945)
+ assertThat(item.isLive).isTrue()
+ assertThat(state.isSheetDraggable).isTrue()
+ }
+ }
+
+ @Test
+ fun `live mode handles invalid geo uri gracefully`() = runTest {
+ val validUserId = UserId("@alice:matrix.org")
+ val invalidUserId = UserId("@bob:matrix.org")
+ val liveSharesFlow = MutableStateFlow(
+ listOf(
+ aLiveLocationShare(userId = validUserId),
+ aLiveLocationShare(userId = invalidUserId, geoUri = "invalid-geo-uri"),
+ )
+ )
+ val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
+
+ val presenter = createShowLocationPresenter(
+ mode = ShowLocationMode.Live(senderId = validUserId),
+ joinedRoom = fakeRoom,
+ )
+ presenter.test {
+ // Skip initial empty state from collectAsState(initial = emptyList())
+ skipItems(1)
+ val state = awaitItem()
+
+ // Only the valid location share should be present
+ assertThat(state.locationShares).hasSize(1)
+ assertThat(state.locationShares.first().userId).isEqualTo(validUserId)
+ }
+ }
+
+ @Test
+ fun `live mode updates when shares change`() = runTest {
+ val userId = UserId("@bob:matrix.org")
+ val liveSharesFlow = MutableStateFlow(emptyList())
+ val fakeRoom = FakeJoinedRoom(liveLocationSharesFlow = liveSharesFlow)
+
+ val presenter = createShowLocationPresenter(
+ mode = ShowLocationMode.Live(senderId = userId),
+ joinedRoom = fakeRoom,
+ )
+ presenter.test {
+ // Initial state is empty
+ val initialState = awaitItem()
+ assertThat(initialState.locationShares).isEmpty()
+
+ // Emit a new live share
+ liveSharesFlow.value = listOf(
+ aLiveLocationShare(userId = userId)
+ )
+
+ val updatedState = awaitItem()
+ assertThat(updatedState.locationShares).hasSize(1)
+ assertThat(updatedState.locationShares.first().userId).isEqualTo(userId)
+ }
+ }
+
+ @Test
+ fun `static mode emits location share with correct data`() = runTest {
+ val senderId = UserId("@alice:matrix.org")
+ val senderName = "Alice"
+ val avatarUrl = "https://example.com/avatar.png"
+ val mode = ShowLocationMode.Static(
+ location = location,
+ senderName = senderName,
+ senderId = senderId,
+ senderAvatarUrl = avatarUrl,
+ timestamp = 0L,
+ assetType = AssetType.SENDER,
+ )
+
+ val presenter = createShowLocationPresenter(mode = mode)
+ presenter.test {
+ val state = awaitItem()
+ assertThat(state.locationShares).hasSize(1)
+
+ val item = state.locationShares.first()
+ assertThat(item.userId).isEqualTo(senderId)
+ assertThat(item.displayName).isEqualTo(senderName)
+ assertThat(item.location).isEqualTo(location)
+ assertThat(item.isLive).isFalse()
+ assertThat(item.assetType).isEqualTo(AssetType.SENDER)
+ assertThat(item.avatarData.id).isEqualTo(senderId.value)
+ assertThat(item.avatarData.name).isEqualTo(senderName)
+ assertThat(item.avatarData.url).isEqualTo(avatarUrl)
+ }
+ }
+
+ @Test
+ fun `static mode has non-draggable sheet`() = runTest {
+ val presenter = createShowLocationPresenter()
+ presenter.test {
+ val state = awaitItem()
+ assertThat(state.isSheetDraggable).isFalse()
+ }
+ }
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt
index fecbbdbf89..45ed894f97 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt
@@ -6,16 +6,19 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.location.impl.show
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
@@ -26,115 +29,111 @@ 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 org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ShowLocationViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `test back action`() {
+ fun `test back action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setShowLocationView(
+ setShowLocationView(
state = aShowLocationState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `test share action`() {
+ fun `test share action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShowLocationView(
+ setShowLocationView(
aShowLocationState(
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
- val shareContentDescription = rule.activity.getString(CommonStrings.action_share)
- rule.onNodeWithContentDescription(shareContentDescription).performClick()
+ val shareContentDescription = activity!!.getString(CommonStrings.action_share)
+ onNodeWithContentDescription(shareContentDescription).performClick()
// The default aStaticLocationMode uses Location(1.23, 2.34, 4f)
eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f)))
}
@Test
- fun `test fab click`() {
+ fun `test fab click`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShowLocationView(
+ setShowLocationView(
aShowLocationState(
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
- rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick()
+ onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true))
}
@Test
- fun `when permission denied is displayed user can open the settings`() {
+ fun `when permission denied is displayed user can open the settings`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShowLocationView(
+ setShowLocationView(
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings)
}
@Test
- fun `when permission denied is displayed user can close the dialog`() {
+ fun `when permission denied is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShowLocationView(
+ setShowLocationView(
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
}
@Test
- fun `when permission rationale is displayed user can request permissions`() {
+ fun `when permission rationale is displayed user can request permissions`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShowLocationView(
+ setShowLocationView(
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions)
}
@Test
- fun `when permission rationale is displayed user can close the dialog`() {
+ fun `when permission rationale is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setShowLocationView(
+ setShowLocationView(
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder
),
onBackClick = EnsureNeverCalled(),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
}
}
-private fun AndroidComposeTestRule.setShowLocationView(
+private fun AndroidComposeUiTest.setShowLocationView(
state: ShowLocationState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt
new file mode 100644
index 0000000000..c42469e705
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/store/LiveLocationStoreTest.kt
@@ -0,0 +1,129 @@
+/*
+ * 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.location.impl.store
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.mutablePreferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.location.impl.live.LiveLocationStore
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.test.A_ROOM_ID
+import io.element.android.libraries.matrix.test.A_SESSION_ID
+import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
+import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.time.Instant
+
+class LiveLocationStoreTest {
+ private val preferenceDataStoreFactory = FakePreferenceDataStoreFactory()
+
+ @Test
+ fun `disclaimer defaults to false`() = runTest {
+ val store = LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = A_SESSION_ID,
+ )
+
+ assertThat(store.hasAcceptedLiveLocationDisclaimer()).isFalse()
+ }
+
+ @Test
+ fun `disclaimer acceptance is isolated per session`() = runTest {
+ val firstStore = LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = A_SESSION_ID,
+ )
+ val secondStore = LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = SessionId("@other:server"),
+ )
+
+ firstStore.setAcceptedLiveLocationDisclaimer().getOrThrow()
+
+ assertThat(firstStore.hasAcceptedLiveLocationDisclaimer()).isTrue()
+ assertThat(secondStore.hasAcceptedLiveLocationDisclaimer()).isFalse()
+ }
+
+ @Test
+ fun `can persist and read expiry per room`() = runTest {
+ val store = LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = A_SESSION_ID,
+ )
+
+ store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
+
+ assertThat(store.getLiveLocationExpiries())
+ .containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L))
+ }
+
+ @Test
+ fun `removing one expiry leaves others untouched`() = runTest {
+ val otherRoomId = RoomId("!other:server")
+ val store = LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = A_SESSION_ID,
+ )
+
+ store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
+ store.setLiveLocationExpiry(otherRoomId, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
+ store.removeLiveLocationExpiry(A_ROOM_ID).getOrThrow()
+
+ assertThat(store.getLiveLocationExpiries())
+ .containsExactly(otherRoomId, Instant.fromEpochMilliseconds(2_000L))
+ }
+
+ @Test
+ fun `setting expiry twice replaces the existing room value`() = runTest {
+ val store = LiveLocationStore(
+ preferenceDataStoreFactory = preferenceDataStoreFactory,
+ sessionId = A_SESSION_ID,
+ )
+
+ store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(1_000L)).getOrThrow()
+ store.setLiveLocationExpiry(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L)).getOrThrow()
+
+ assertThat(store.getLiveLocationExpiries())
+ .containsExactly(A_ROOM_ID, Instant.fromEpochMilliseconds(2_000L))
+ }
+
+ @Test
+ fun `malformed expiry payload returns empty map`() = runTest {
+ val store = LiveLocationStore(
+ preferenceDataStoreFactory = createMalformedExpiryPreferenceDataStoreFactory(),
+ sessionId = A_SESSION_ID,
+ )
+
+ assertThat(store.getLiveLocationExpiries()).isEmpty()
+ }
+
+ private fun createMalformedExpiryPreferenceDataStoreFactory(): PreferenceDataStoreFactory {
+ return object : PreferenceDataStoreFactory {
+ override fun create(name: String): DataStore {
+ var preferences: Preferences = mutablePreferencesOf(
+ stringPreferencesKey("live_location_expiries") to "not valid"
+ )
+ return object : DataStore {
+ override val data: Flow
+ get() = flowOf(preferences)
+
+ override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
+ preferences = transform(preferences)
+ return preferences
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/features/location/test/build.gradle.kts b/features/location/test/build.gradle.kts
index f84e8ba772..e51737d40c 100644
--- a/features/location/test/build.gradle.kts
+++ b/features/location/test/build.gradle.kts
@@ -16,7 +16,7 @@ android {
dependencies {
api(projects.features.location.api)
+ implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
- implementation(libs.appyx.core)
implementation(projects.tests.testutils)
}
diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.kt
new file mode 100644
index 0000000000..255c181ac1
--- /dev/null
+++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeActiveLiveLocationShareManager.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.location.test
+
+import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.simulateLongTask
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlin.time.Duration
+
+class FakeActiveLiveLocationShareManager(
+ val setupLambda: () -> Unit = { lambdaError() },
+ val startShareLambda: (roomId: RoomId, duration: Duration) -> Result = { _, _ -> lambdaError() },
+ val stopShareLambda: (roomId: RoomId) -> Result = { _ -> lambdaError() },
+) : ActiveLiveLocationShareManager {
+ private val _sharingRoomIds = MutableStateFlow(emptySet())
+ override val sharingRoomIds: StateFlow> = _sharingRoomIds
+
+ override suspend fun setup() {
+ setupLambda()
+ }
+
+ override suspend fun startShare(roomId: RoomId, duration: Duration): Result = simulateLongTask {
+ startShareLambda(roomId, duration).onSuccess {
+ _sharingRoomIds.update {
+ it + roomId
+ }
+ }
+ }
+
+ override suspend fun stopShare(roomId: RoomId): Result = simulateLongTask {
+ stopShareLambda(roomId).onSuccess {
+ _sharingRoomIds.update {
+ it - roomId
+ }
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
index 7ac42feda6..49d299b3f9 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
@@ -43,7 +43,7 @@ class DefaultLockScreenService(
private val coroutineScope: CoroutineScope,
private val sessionObserver: SessionObserver,
private val appForegroundStateService: AppForegroundStateService,
- biometricAuthenticatorManager: BiometricAuthenticatorManager,
+ private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
) : LockScreenService {
private val _lockState = MutableStateFlow(LockScreenLockState.Unlocked)
override val lockState: StateFlow = _lockState
@@ -81,6 +81,7 @@ class DefaultLockScreenService(
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
if (wasLastSession) {
pinCodeManager.deletePinCode()
+ biometricAuthenticatorManager.disable()
}
}
})
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
index a96c713ff2..d18d9b73b7 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt
@@ -36,13 +36,13 @@ interface BiometricAuthenticator {
}
val isActive: Boolean
- fun setup()
+ suspend fun setup()
suspend fun authenticate(): AuthenticationResult
}
class NoopBiometricAuthentication : BiometricAuthenticator {
override val isActive: Boolean = false
- override fun setup() = Unit
+ override suspend fun setup() = Unit
override suspend fun authenticate() = BiometricAuthenticator.AuthenticationResult.Failure()
}
@@ -58,7 +58,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
- override fun setup() {
+ override suspend fun setup() {
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -86,7 +86,7 @@ class DefaultBiometricAuthentication(
}
@Throws(KeyPermanentlyInvalidatedException::class)
- private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
+ private suspend fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
encryptionDecryptionService.createEncryptionCipher(it)
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt
index 9917845725..2ea0ed7d05 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticatorManager.kt
@@ -24,6 +24,11 @@ interface BiometricAuthenticatorManager {
fun addCallback(callback: BiometricAuthenticator.Callback)
fun removeCallback(callback: BiometricAuthenticator.Callback)
+ /**
+ * Disable using the biometric unlock feature and remove any data associated with it.
+ */
+ suspend fun disable()
+
/**
* Remember a biometric authenticator ready for unlocking the app.
*/
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
index 8bb044fd06..117323ec0a 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricAuthenticatorManager.kt
@@ -80,10 +80,7 @@ class DefaultBiometricAuthenticatorManager(
private val internalCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricSetupError() {
- coroutineScope.launch {
- lockScreenStore.setIsBiometricUnlockAllowed(false)
- secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
- }
+ coroutineScope.launch { disable() }
}
}
@@ -120,6 +117,11 @@ class DefaultBiometricAuthenticatorManager(
)
}
+ override suspend fun disable() {
+ lockScreenStore.setIsBiometricUnlockAllowed(false)
+ secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
+ }
+
@Composable
private fun rememberBiometricAuthenticator(
isAvailable: Boolean,
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 091432044a..d699357933 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
@@ -16,9 +16,13 @@ import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import java.util.concurrent.CopyOnWriteArrayList
-private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
+internal const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
@@ -29,6 +33,8 @@ class DefaultPinCodeManager(
) : PinCodeManager {
private val callbacks = CopyOnWriteArrayList()
+ private val migrationMutex = Mutex()
+
override fun addCallback(callback: PinCodeManager.Callback) {
callbacks.add(callback)
}
@@ -38,11 +44,20 @@ class DefaultPinCodeManager(
}
override fun hasPinCode(): Flow {
- return lockScreenStore.hasPinCode()
+ return secretKeyRepository.hasKey(SECRET_KEY_ALIAS)
+ .onStart {
+ migrationMutex.withLock {
+ val hasKey = secretKeyRepository.hasKey(SECRET_KEY_ALIAS).first()
+ if (hasKey && lockScreenStore.getEncryptedCode() == null) {
+ // Remove the key if there is no pin code
+ secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
+ }
+ }
+ }
}
- override suspend fun getPinCodeSize(): Int {
- val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
+ override suspend fun getPinCodeSize(): Int? {
+ val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return null
val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
return decryptedPinCode.size
@@ -79,6 +94,7 @@ class DefaultPinCodeManager(
override suspend fun deletePinCode() {
lockScreenStore.deleteEncryptedPinCode()
lockScreenStore.resetCounter()
+ secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
callbacks.forEach { it.onPinCodeRemoved() }
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt
index 9282f3e7df..350631a233 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt
@@ -51,9 +51,9 @@ interface PinCodeManager {
fun hasPinCode(): Flow
/**
- * @return the size of the saved pin code.
+ * @return the size of the saved pin code. Return null if no pin code is saved.
*/
- suspend fun getPinCodeSize(): Int
+ suspend fun getPinCodeSize(): Int?
/**
* Creates a new encrypted pin code.
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvent.kt
similarity index 62%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvent.kt
index 2d62427e02..c7437912eb 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvent.kt
@@ -8,9 +8,9 @@
package io.element.android.features.lockscreen.impl.settings
-sealed interface LockScreenSettingsEvents {
- data object OnRemovePin : LockScreenSettingsEvents
- data object ConfirmRemovePin : LockScreenSettingsEvents
- data object CancelRemovePin : LockScreenSettingsEvents
- data object ToggleBiometricAllowed : LockScreenSettingsEvents
+sealed interface LockScreenSettingsEvent {
+ data object OnRemovePin : LockScreenSettingsEvent
+ data object ConfirmRemovePin : LockScreenSettingsEvent
+ data object CancelRemovePin : LockScreenSettingsEvent
+ data object ToggleBiometricAllowed : LockScreenSettingsEvent
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
index 589794bde0..17a0213f63 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
@@ -51,19 +51,20 @@ class LockScreenSettingsPresenter(
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
- fun handleEvent(event: LockScreenSettingsEvents) {
+ fun handleEvent(event: LockScreenSettingsEvent) {
when (event) {
- LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
- LockScreenSettingsEvents.ConfirmRemovePin -> {
+ LockScreenSettingsEvent.CancelRemovePin -> showRemovePinConfirmation = false
+ LockScreenSettingsEvent.ConfirmRemovePin -> {
coroutineScope.launch {
if (showRemovePinConfirmation) {
showRemovePinConfirmation = false
pinCodeManager.deletePinCode()
+ biometricAuthenticatorManager.disable()
}
}
}
- LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
- LockScreenSettingsEvents.ToggleBiometricAllowed -> {
+ LockScreenSettingsEvent.OnRemovePin -> showRemovePinConfirmation = true
+ LockScreenSettingsEvent.ToggleBiometricAllowed -> {
coroutineScope.launch {
if (!isBiometricEnabled) {
biometricUnlock.setup()
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt
index a69d633508..62b8d6d4ee 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt
@@ -13,5 +13,5 @@ data class LockScreenSettingsState(
val isBiometricEnabled: Boolean,
val showRemovePinConfirmation: Boolean,
val showToggleBiometric: Boolean,
- val eventSink: (LockScreenSettingsEvents) -> Unit
+ val eventSink: (LockScreenSettingsEvent) -> Unit
)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
index fe5f20da0d..e78a5ee002 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
@@ -51,7 +51,7 @@ fun LockScreenSettingsView(
},
style = ListItemStyle.Destructive,
onClick = {
- state.eventSink(LockScreenSettingsEvents.OnRemovePin)
+ state.eventSink(LockScreenSettingsEvent.OnRemovePin)
}
)
}
@@ -61,7 +61,7 @@ fun LockScreenSettingsView(
title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
isChecked = state.isBiometricEnabled,
onCheckedChange = {
- state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
}
)
}
@@ -72,10 +72,10 @@ fun LockScreenSettingsView(
title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
onSubmitClick = {
- state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
+ state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
},
onDismiss = {
- state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
+ state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
}
)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvent.kt
similarity index 68%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvent.kt
index ab8b18642e..d4db46b731 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvent.kt
@@ -8,7 +8,7 @@
package io.element.android.features.lockscreen.impl.setup.biometric
-sealed interface SetupBiometricEvents {
- data object AllowBiometric : SetupBiometricEvents
- data object UsePin : SetupBiometricEvents
+sealed interface SetupBiometricEvent {
+ data object AllowBiometric : SetupBiometricEvent
+ data object UsePin : SetupBiometricEvent
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
index 3af2a28851..ce914320bc 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
@@ -35,16 +35,16 @@ class SetupBiometricPresenter(
val coroutineScope = rememberCoroutineScope()
val biometricUnlock = biometricAuthenticatorManager.rememberConfirmBiometricAuthenticator()
- fun handleEvent(event: SetupBiometricEvents) {
+ fun handleEvent(event: SetupBiometricEvent) {
when (event) {
- SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
+ SetupBiometricEvent.AllowBiometric -> coroutineScope.launch {
biometricUnlock.setup()
if (biometricUnlock.authenticate() == BiometricAuthenticator.AuthenticationResult.Success) {
lockScreenStore.setIsBiometricUnlockAllowed(true)
isBiometricSetupDone = true
}
}
- SetupBiometricEvents.UsePin -> coroutineScope.launch {
+ SetupBiometricEvent.UsePin -> coroutineScope.launch {
lockScreenStore.setIsBiometricUnlockAllowed(false)
isBiometricSetupDone = true
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt
index 2843c028d1..db11b1dc30 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt
@@ -10,5 +10,5 @@ package io.element.android.features.lockscreen.impl.setup.biometric
data class SetupBiometricState(
val isBiometricSetupDone: Boolean,
- val eventSink: (SetupBiometricEvents) -> Unit
+ val eventSink: (SetupBiometricEvent) -> Unit
)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
index 35b1ec76c0..70a1046e36 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
@@ -33,7 +33,7 @@ fun SetupBiometricView(
modifier: Modifier = Modifier,
) {
BackHandler {
- state.eventSink(SetupBiometricEvents.UsePin)
+ state.eventSink(SetupBiometricEvent.UsePin)
}
HeaderFooterPage(
modifier = modifier.padding(top = 80.dp),
@@ -42,8 +42,8 @@ fun SetupBiometricView(
},
footer = {
SetupBiometricFooter(
- onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
- onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) }
+ onAllowClick = { state.eventSink(SetupBiometricEvent.AllowBiometric) },
+ onSkipClick = { state.eventSink(SetupBiometricEvent.UsePin) }
)
},
)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvent.kt
similarity index 74%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvent.kt
index 276a94b2fc..f0dfdc33f0 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvent.kt
@@ -8,7 +8,7 @@
package io.element.android.features.lockscreen.impl.setup.pin
-sealed interface SetupPinEvents {
- data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents
- data object ClearFailure : SetupPinEvents
+sealed interface SetupPinEvent {
+ data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvent
+ data object ClearFailure : SetupPinEvent
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
index ac5b5bd1cc..d780927d44 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
@@ -74,9 +74,9 @@ class SetupPinPresenter(
}
}
- fun handleEvent(event: SetupPinEvents) {
+ fun handleEvent(event: SetupPinEvent) {
when (event) {
- is SetupPinEvents.OnPinEntryChanged -> {
+ is SetupPinEvent.OnPinEntryChanged -> {
// Use the fromConfirmationStep flag from ui to avoid race condition.
if (event.fromConfirmationStep) {
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
@@ -84,7 +84,7 @@ class SetupPinPresenter(
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
}
}
- SetupPinEvents.ClearFailure -> {
+ SetupPinEvent.ClearFailure -> {
when (setupPinFailure) {
is SetupPinFailure.PinsDoNotMatch -> {
choosePinEntry = choosePinEntry.clear()
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt
index 2d5124d440..cf65e63c1b 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt
@@ -17,7 +17,7 @@ data class SetupPinState(
val isConfirmationStep: Boolean,
val setupPinFailure: SetupPinFailure?,
val appName: String,
- val eventSink: (SetupPinEvents) -> Unit
+ val eventSink: (SetupPinEvent) -> Unit
) {
val activePinEntry = if (isConfirmationStep) {
confirmPinEntry
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
index 5f2320db32..508d3c1fbb 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
@@ -107,7 +107,7 @@ private fun SetupPinContent(
pinEntry = state.activePinEntry,
isSecured = true,
onValueChange = { entry ->
- state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
+ state.eventSink(SetupPinEvent.OnPinEntryChanged(entry, state.isConfirmationStep))
},
modifier = Modifier
.focusRequester(focusRequester)
@@ -119,7 +119,7 @@ private fun SetupPinContent(
title = state.setupPinFailure.title(),
content = state.setupPinFailure.content(),
onSubmit = {
- state.eventSink(SetupPinEvents.ClearFailure)
+ state.eventSink(SetupPinEvent.ClearFailure)
}
)
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt
index c4558812de..b41e6a9578 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt
@@ -8,8 +8,6 @@
package io.element.android.features.lockscreen.impl.storage
-import kotlinx.coroutines.flow.Flow
-
/**
* Should be implemented by any class that provides access to the encrypted PIN code.
* All methods are suspending in case there are async IO operations involved.
@@ -29,9 +27,4 @@ interface EncryptedPinCodeStorage {
* Deletes the PIN code from some persistable storage.
*/
suspend fun deleteEncryptedPinCode()
-
- /**
- * Returns whether the PIN code is stored or not.
- */
- fun hasPinCode(): Flow
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
index 6b99d90592..bce20b2418 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
@@ -70,12 +70,6 @@ class PreferencesLockScreenStore(
}
}
- override fun hasPinCode(): Flow {
- return dataStore.data.map { preferences ->
- preferences[pinCodeKey] != null
- }
- }
-
override fun isBiometricUnlockAllowed(): Flow {
return dataStore.data.map { preferences ->
preferences[biometricUnlockKey] ?: false
@@ -88,5 +82,7 @@ class PreferencesLockScreenStore(
}
}
- private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
+ private fun Preferences.getRemainingPinCodeAttemptsNumber() =
+ this[remainingAttemptsKey]?.coerceIn(0, lockScreenConfig.maxPinCodeAttemptsBeforeLogout)
+ ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvent.kt
similarity index 61%
rename from features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt
rename to features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvent.kt
index bd9043859f..aa96a2e115 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvent.kt
@@ -10,12 +10,12 @@ package io.element.android.features.lockscreen.impl.unlock
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
-sealed interface PinUnlockEvents {
- data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
- data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents
- data object OnForgetPin : PinUnlockEvents
- data object ClearSignOutPrompt : PinUnlockEvents
- data object SignOut : PinUnlockEvents
- data object OnUseBiometric : PinUnlockEvents
- data object ClearBiometricError : PinUnlockEvents
+sealed interface PinUnlockEvent {
+ data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvent
+ data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvent
+ data object OnForgetPin : PinUnlockEvent
+ data object ClearSignOutPrompt : PinUnlockEvent
+ data object SignOut : PinUnlockEvent
+ data object OnUseBiometric : PinUnlockEvent
+ data object ClearBiometricError : PinUnlockEvent
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
index 5429320fc7..c8dd8916f9 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
@@ -69,7 +69,13 @@ class PinUnlockPresenter(
LaunchedEffect(Unit) {
suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize()
- PinEntry.createEmpty(pinCodeSize)
+ if (pinCodeSize == null) {
+ // No pin code set, deleted store? Force sign out
+ showSignOutPrompt = true
+ error("No pin code size found")
+ } else {
+ PinEntry.createEmpty(pinCodeSize)
+ }
}.runCatchingUpdatingState(pinEntryState)
}
LaunchedEffect(biometricUnlock) {
@@ -95,28 +101,28 @@ class PinUnlockPresenter(
isUnlocked.value = true
}
- fun handleEvent(event: PinUnlockEvents) {
+ fun handleEvent(event: PinUnlockEvent) {
when (event) {
- is PinUnlockEvents.OnPinKeypadPressed -> {
+ is PinUnlockEvent.OnPinKeypadPressed -> {
pinEntryState.value = pinEntry.process(event.pinKeypadModel)
}
- PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
- PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
- PinUnlockEvents.SignOut -> {
+ PinUnlockEvent.OnForgetPin -> showSignOutPrompt = true
+ PinUnlockEvent.ClearSignOutPrompt -> showSignOutPrompt = false
+ PinUnlockEvent.SignOut -> {
if (showSignOutPrompt) {
showSignOutPrompt = false
coroutineScope.signOut(signOutAction)
}
}
- PinUnlockEvents.OnUseBiometric -> {
+ PinUnlockEvent.OnUseBiometric -> {
coroutineScope.launch {
biometricUnlockResult = biometricUnlock.authenticate()
}
}
- PinUnlockEvents.ClearBiometricError -> {
+ PinUnlockEvent.ClearBiometricError -> {
biometricUnlockResult = null
}
- is PinUnlockEvents.OnPinEntryChanged -> {
+ is PinUnlockEvent.OnPinEntryChanged -> {
pinEntryState.value = pinEntry.process(event.entryAsText)
}
}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
index 2bbcbe335c..037aa87dec 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
@@ -23,11 +23,15 @@ data class PinUnlockState(
val showBiometricUnlock: Boolean,
val isUnlocked: Boolean,
val biometricUnlockResult: BiometricAuthenticator.AuthenticationResult?,
- val eventSink: (PinUnlockEvents) -> Unit
+ val eventSink: (PinUnlockEvent) -> Unit
) {
- val isSignOutPromptCancellable = when (remainingAttempts) {
- is AsyncData.Success -> remainingAttempts.data > 0
- else -> true
+ val isSignOutPromptCancellable = if (pinEntry.isFailure()) {
+ false
+ } else {
+ when (remainingAttempts) {
+ is AsyncData.Success -> remainingAttempts.data > 0
+ else -> true
+ }
}
val biometricUnlockErrorMessage = when {
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
index 2beb8babe3..1b8166a8ac 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
@@ -20,7 +20,7 @@ open class PinUnlockStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aPinUnlockState(),
- aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
+ aPinUnlockState(pinEntry = AsyncData.Success(PinEntry.createEmpty(4).fillWith("12"))),
aPinUnlockState(showWrongPinTitle = true),
aPinUnlockState(showSignOutPrompt = true),
aPinUnlockState(showBiometricUnlock = false),
@@ -31,11 +31,18 @@ open class PinUnlockStateProvider : PreviewParameterProvider {
BiometricUnlockError(BiometricPrompt.ERROR_LOCKOUT, "Biometric auth disabled")
)
),
+ aPinUnlockState(showSignOutPrompt = true, pinEntry = AsyncData.Failure(Exception("An error occurred"))),
+ // User enter wrong pin once, and then correct PIN. In this case, the error (with counter reset to 3) should not be displayed.
+ aPinUnlockState(
+ remainingAttempts = AsyncData.Success(2),
+ showWrongPinTitle = true,
+ isUnlocked = true,
+ ),
)
}
fun aPinUnlockState(
- pinEntry: PinEntry = PinEntry.createEmpty(4),
+ pinEntry: AsyncData = AsyncData.Success(PinEntry.createEmpty(4)),
remainingAttempts: AsyncData = AsyncData.Success(3),
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
@@ -44,7 +51,7 @@ fun aPinUnlockState(
isUnlocked: Boolean = false,
signOutAction: AsyncAction = AsyncAction.Uninitialized,
) = PinUnlockState(
- pinEntry = AsyncData.Success(pinEntry),
+ pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
showSignOutPrompt = showSignOutPrompt,
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
index 659f8c2966..6749697b64 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
@@ -69,7 +69,7 @@ fun PinUnlockView(
) {
OnLifecycleEvent { _, event ->
when (event) {
- Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
+ Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvent.OnUseBiometric)
else -> Unit
}
}
@@ -78,8 +78,8 @@ fun PinUnlockView(
if (state.showSignOutPrompt) {
SignOutPrompt(
isCancellable = state.isSignOutPromptCancellable,
- onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
- onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
+ onSignOut = { state.eventSink(PinUnlockEvent.SignOut) },
+ onDismiss = { state.eventSink(PinUnlockEvent.ClearSignOutPrompt) },
)
}
when (state.signOutAction) {
@@ -95,7 +95,7 @@ fun PinUnlockView(
if (state.showBiometricUnlockError) {
ErrorDialog(
content = state.biometricUnlockErrorMessage ?: "",
- onSubmit = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
+ onSubmit = { state.eventSink(PinUnlockEvent.ClearBiometricError) }
)
}
}
@@ -108,10 +108,10 @@ private fun PinUnlockPage(
) {
BoxWithConstraints {
val commonModifier = Modifier
- .fillMaxSize()
- .systemBarsPadding()
- .imePadding()
- .padding(all = 20.dp)
+ .fillMaxSize()
+ .systemBarsPadding()
+ .imePadding()
+ .padding(all = 20.dp)
val header = @Composable {
PinUnlockHeader(
@@ -125,10 +125,10 @@ private fun PinUnlockPage(
modifier = Modifier.padding(top = 24.dp),
showBiometricUnlock = state.showBiometricUnlock,
onUseBiometric = {
- state.eventSink(PinUnlockEvents.OnUseBiometric)
+ state.eventSink(PinUnlockEvent.OnUseBiometric)
},
onForgotPin = {
- state.eventSink(PinUnlockEvents.OnForgetPin)
+ state.eventSink(PinUnlockEvent.OnForgetPin)
},
)
}
@@ -144,17 +144,17 @@ private fun PinUnlockPage(
pinEntry = pinEntry,
isSecured = true,
onValueChange = {
- state.eventSink(PinUnlockEvents.OnPinEntryChanged(it))
+ state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
},
modifier = Modifier
- .focusRequester(focusRequester)
- .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .fillMaxWidth()
)
}
} else {
PinKeypad(
onClick = {
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(it))
},
maxWidth = constraints.maxWidth,
maxHeight = constraints.maxHeight,
@@ -217,8 +217,8 @@ private fun PinUnlockCompactView(
}
BoxWithConstraints(
modifier = Modifier
- .weight(1f)
- .fillMaxHeight(),
+ .weight(1f)
+ .fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
content()
@@ -239,9 +239,9 @@ private fun PinUnlockExpandedView(
header()
BoxWithConstraints(
modifier = Modifier
- .weight(1f)
- .fillMaxWidth()
- .padding(top = 40.dp),
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(top = 40.dp),
) {
content()
}
@@ -274,8 +274,8 @@ private fun PinDot(
}
Box(
modifier = Modifier
- .size(14.dp)
- .background(backgroundColor, CircleShape)
+ .size(14.dp)
+ .background(backgroundColor, CircleShape)
)
}
@@ -311,14 +311,26 @@ private fun PinUnlockHeader(
)
Spacer(Modifier.height(8.dp))
val remainingAttempts = state.remainingAttempts.dataOrNull()
- val subtitle = if (remainingAttempts != null) {
- if (state.showWrongPinTitle) {
- pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = remainingAttempts, remainingAttempts)
- } else {
- pluralStringResource(id = R.plurals.screen_app_lock_subtitle, count = remainingAttempts, remainingAttempts)
+ val subtitle = when {
+ state.isUnlocked -> {
+ // Hide any previous error
+ ""
}
- } else {
- ""
+ remainingAttempts != null ->
+ if (state.showWrongPinTitle) {
+ pluralStringResource(
+ id = R.plurals.screen_app_lock_subtitle_wrong_pin,
+ count = remainingAttempts,
+ remainingAttempts,
+ )
+ } else {
+ pluralStringResource(
+ id = R.plurals.screen_app_lock_subtitle,
+ count = remainingAttempts,
+ remainingAttempts,
+ )
+ }
+ else -> ""
}
val subtitleColor = if (state.showWrongPinTitle) {
ElementTheme.colors.textCriticalPrimary
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt
index 6209c19be2..34adfeca99 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt
@@ -30,6 +30,7 @@ import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
+import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.launch
@@ -43,6 +44,7 @@ class PinUnlockActivity : AppCompatActivity() {
@Inject lateinit var presenter: PinUnlockPresenter
@Inject lateinit var lockScreenService: LockScreenService
@Inject lateinit var appPreferencesStore: AppPreferencesStore
+ @Inject lateinit var featureFlagService: FeatureFlagService
@Inject lateinit var enterpriseService: EnterpriseService
@Inject lateinit var buildMeta: BuildMeta
@@ -56,6 +58,7 @@ class PinUnlockActivity : AppCompatActivity() {
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
+ featureFlagService = featureFlagService,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
diff --git a/features/lockscreen/impl/src/main/res/values-ca/translations.xml b/features/lockscreen/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..5637977b97
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,38 @@
+
+
+ "l\'autenticació biomètrica"
+ "desbloqueig biomètric"
+ "Desbloqueja amb biometria"
+ "Confirma biometria"
+ "Has oblidat el PIN?"
+ "Canvia codi PIN"
+ "Permet desbloqueig biomètric"
+ "Elimina PIN"
+ "Segur que vols eliminar el PIN?"
+ "Vols eliminar el PIN?"
+ "Permet %1$s"
+ "Prefereixo utilitzar el PIN"
+ "Estalvia\'t temps i utilitza %1$s per desbloquejar l\'aplicació"
+ "Escull el PIN"
+ "Confirma PIN"
+ "Bloqueja %1$s per afegir més seguretat als teus xats.
+
+Escull alguna cosa que recordis. Si oblides aquest PIN, es tancarà sessió a l\'aplicació."
+ "Per motius de seguretat no pots utilitzar aquest codi PIN"
+ "Escull un PIN diferent"
+ "Introdueix el mateix PIN dues vegades"
+ "Els codis PIN no coincideixen"
+ "Hauràs de tornar a iniciar sessió i crear un nou PIN per continuar."
+ "S\'està tancant la sessió"
+
+ "Tens %1$d intent per desbloquejar"
+ "Tens %1$d intents per desbloquejar"
+
+
+ "PIN incorrecte. Tens %1$d intent més"
+ "PIN incorrecte. Tens %1$d intents més"
+
+ "Utilitza biometria"
+ "Utilitza PIN"
+ "S\'està tancant la sessió…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-de/translations.xml b/features/lockscreen/impl/src/main/res/values-de/translations.xml
index dd74818610..e1819583f0 100644
--- a/features/lockscreen/impl/src/main/res/values-de/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-de/translations.xml
@@ -23,7 +23,7 @@ Wähle eine einprägsame PIN. Wenn du sie vergisst, wirst du aus der App abgemel
"Bitte gib die gleiche PIN wie zuvor ein.""Die PINs stimmen nicht überein""Um fortzufahren, musst du dich erneut anmelden und eine neue PIN erstellen"
- "Du wirst abgemeldet"
+ "Dieses Gerät wurde entfernt""Du hast %1$d Versuch, um zu entsperren""Du hast %1$d Versuche, um zu entsperren"
@@ -34,5 +34,5 @@ Wähle eine einprägsame PIN. Wenn du sie vergisst, wirst du aus der App abgemel
"Biometrie verwenden""PIN verwenden"
- "Abmelden…"
+ "Gerät wird entfernt…"
diff --git a/features/lockscreen/impl/src/main/res/values-et/translations.xml b/features/lockscreen/impl/src/main/res/values-et/translations.xml
index 4449479ba6..7137c09b60 100644
--- a/features/lockscreen/impl/src/main/res/values-et/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-et/translations.xml
@@ -23,7 +23,7 @@ Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvaka
"Palun sisesta sama PIN-kood kaks korda""PIN-koodid ei klapi omavahel""Jätkamaks pead uuesti sisse logima ja looma uue PIN-koodi"
- "Sa oled logimas välja"
+ "See seade on eemaldamisel""Sul on lukustuse eemaldamiseks jäänud %1$d katse""Sul on lukustuse eemaldamiseks jäänud %1$d katset"
@@ -34,5 +34,5 @@ Vali midagi, mis hästi meelde jääb. Kui unustad selle PIN-koodi, siis turvaka
"Kasuta biomeetriat""Kasuta PIN-koodi"
- "Logime välja…"
+ "Eemaldan seadet…"
diff --git a/features/lockscreen/impl/src/main/res/values-fa/translations.xml b/features/lockscreen/impl/src/main/res/values-fa/translations.xml
index 56dc91e835..0575f22221 100644
--- a/features/lockscreen/impl/src/main/res/values-fa/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-fa/translations.xml
@@ -23,7 +23,7 @@
"لطفاً یک پین را دو بار وارد کنید""پینها مطابق نیستند""برای ادامه باید دوباره وارد شده و پینی جدید ایجاد کنید"
- "دارید خارج میشوید"
+ "این دستگاه در حال حذف شدن است""شما %1$d تلاش برای باز کردن قفل دارید""شما %1$d تلاش برای باز کردن قفل دارید"
@@ -34,5 +34,5 @@
"استفاده از زیستسنجی""استفاده از پین"
- "خارج شدن…"
+ "برداشتن افزاره…"
diff --git a/features/lockscreen/impl/src/main/res/values-hr/translations.xml b/features/lockscreen/impl/src/main/res/values-hr/translations.xml
index 1a81bcc6cb..232775c798 100644
--- a/features/lockscreen/impl/src/main/res/values-hr/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-hr/translations.xml
@@ -23,7 +23,7 @@ Odaberite nešto nezaboravno. Ako zaboravite ovaj PIN, bit ćete odjavljeni iz a
"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"
+ "Ovaj uređaj se uklanja""Imate %1$d pokušaj otključavanja""Imate %1$d pokušaja otključavanja"
@@ -36,5 +36,5 @@ Odaberite nešto nezaboravno. Ako zaboravite ovaj PIN, bit ćete odjavljeni iz a
"Upotrijebi biometriju""Upotrijebi PIN"
- "Odjavljivanje…"
+ "Uklanjanje uređaja…"
diff --git a/features/lockscreen/impl/src/main/res/values-in/translations.xml b/features/lockscreen/impl/src/main/res/values-in/translations.xml
index 0396f56b0c..e0054cda62 100644
--- a/features/lockscreen/impl/src/main/res/values-in/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-in/translations.xml
@@ -32,5 +32,5 @@ Pilih sesuatu yang mudah untuk diingat. Jika Anda lupa PIN ini, Anda akan dikelu
"Gunakan biometrik""Gunakan PIN"
- "Mengeluarkan dari akun…"
+ "Mengeluarkan device dari akun…"
diff --git a/features/lockscreen/impl/src/main/res/values-pl/translations.xml b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
index 5d61ecb7c6..29691987f6 100644
--- a/features/lockscreen/impl/src/main/res/values-pl/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-pl/translations.xml
@@ -23,7 +23,7 @@ Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz ten PIN, zostaniesz wyl
"Wprowadź ten sam kod PIN dwa razy""PIN\'y nie pasują do siebie""Aby kontynuować, zaloguj się ponownie i utwórz nowy kod PIN"
- "Trwa wylogowywanie"
+ "Trwa usuwanie urządzenia""Masz %1$d próbę, żeby odblokować""Masz %1$d próby, żeby odblokować"
@@ -36,5 +36,5 @@ Wybierz coś łatwego do zapamiętania. Jeśli zapomnisz ten PIN, zostaniesz wyl
"Użyj biometrii""Użyj kodu PIN"
- "Wylogowywanie…"
+ "Usuwam urządzenie…"
diff --git a/features/lockscreen/impl/src/main/res/values-pt/translations.xml b/features/lockscreen/impl/src/main/res/values-pt/translations.xml
index a6b2516fba..fca6fbcb9e 100644
--- a/features/lockscreen/impl/src/main/res/values-pt/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-pt/translations.xml
@@ -23,7 +23,7 @@ Escolhe algo memorável. Se te esqueceres deste PIN, a tua sessão será termina
"Insere o mesmo PIN duas vezes""Os PINs não coincidem""Terás de voltar a iniciar sessão e criar um novo PIN para continuar"
- "Estás a terminar a sessão"
+ "O teu dispositivo está a ser removido""Tens %1$d tentativa de desbloqueio""Tens %1$d tentativas de desbloqueio"
@@ -34,5 +34,5 @@ Escolhe algo memorável. Se te esqueceres deste PIN, a tua sessão será termina
"Utilizar biometria""Utilizar PIN"
- "A terminar sessão…"
+ "A remover dispositivo…"
diff --git a/features/lockscreen/impl/src/main/res/values-ro/translations.xml b/features/lockscreen/impl/src/main/res/values-ro/translations.xml
index d40bfbaece..7555d7eb58 100644
--- a/features/lockscreen/impl/src/main/res/values-ro/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-ro/translations.xml
@@ -23,7 +23,7 @@ Alegeți ceva memorabil. Dacă uitați acest PIN, veți fi deconectat din aplica
"Vă rugăm să introduceți același cod PIN de două ori""Codurile PIN nu corespund""Va trebui să vă reconectați și să creați un cod PIN nou pentru a continua"
- "Sunteți deconectat"
+ "Acest device este în curs de eliminare""Aveți %1$d încercare de deblocare""Aveți %1$d încercări de deblocare"
diff --git a/features/lockscreen/impl/src/main/res/values-sk/translations.xml b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
index 0cfb2e88cd..14687f9272 100644
--- a/features/lockscreen/impl/src/main/res/values-sk/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
@@ -36,5 +36,5 @@ Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplik
"Použiť biometrické údaje""Použiť PIN"
- "Prebieha odhlasovanie…"
+ "Odoberanie zariadenia…"
diff --git a/features/lockscreen/impl/src/main/res/values-uk/translations.xml b/features/lockscreen/impl/src/main/res/values-uk/translations.xml
index 5c19889282..25e96003c8 100644
--- a/features/lockscreen/impl/src/main/res/values-uk/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-uk/translations.xml
@@ -36,5 +36,5 @@
"Використати біометрію""Використати PIN-код"
- "Вихід…"
+ "Видалення пристрою…"
diff --git a/features/lockscreen/impl/src/main/res/values-vi/translations.xml b/features/lockscreen/impl/src/main/res/values-vi/translations.xml
index 2a177b843b..59dd89b114 100644
--- a/features/lockscreen/impl/src/main/res/values-vi/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-vi/translations.xml
@@ -3,6 +3,7 @@
"xác thực sinh trắc học""mở khóa sinh trắc học""Mở khóa bằng sinh trắc học"
+ "Xác nhận sinh trắc học""Quên mã PIN rồi à?""Thay đổi mã PIN""Cho phép mở khóa bằng sinh trắc học"
diff --git a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
index 799db8f84c..2bd329aea4 100644
--- a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
@@ -23,7 +23,7 @@
"請輸入相同的 PIN 碼兩次""PIN 碼不一樣""您需要重新登入並建立新的 PIN 碼才能繼續"
- "您即將登出"
+ "此裝置已被移除""您有 %1$d 次解鎖的機會"
@@ -32,5 +32,5 @@
"使用生物辨識""使用 PIN 碼"
- "正在登出…"
+ "正在移除裝置……"
diff --git a/features/lockscreen/impl/src/main/res/values-zh/translations.xml b/features/lockscreen/impl/src/main/res/values-zh/translations.xml
index defe7a0e32..f3e93668fd 100644
--- a/features/lockscreen/impl/src/main/res/values-zh/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-zh/translations.xml
@@ -8,22 +8,22 @@
"更改 PIN 码""允许生物识别解锁""移除 PIN 码"
- "您确定要删除 PIN 码吗?"
+ "你确定要删除 PIN 码?""移除 PIN 码?""允许 %1$s""我宁愿使用 PIN 码""节省时间,用 %1$s 来解锁应用程序""选择 PIN 码""确认 PIN 码"
- "锁定 %1$s 以为聊天增加安全性。
+ "锁定 %1$s 以增加聊天的安全性。
-选择好记的 PIN 码。如果忘掉了这个 PIN 码,就不得不登出应用。"
- "出于安全原因,您不能选择这个 PIN 码"
+选择好记的 PIN 码。如果忘掉了此 PIN 码,你将被迫从 app 注销。"
+ "出于安全考虑,你不能使用此 PIN 码""选择不同的 PIN 码""请输入两次相同的 PIN 码""PIN 码不匹配"
- "您需要重新登录并创建新的 PIN 才能继续"
- "您正在登出"
+ "你需要重新登录并创建新的 PIN 码才能继续"
+ "正在被移除该设备""还剩 %1$d 次解锁机会"
@@ -32,5 +32,5 @@
"使用生物识别""使用 PIN 码"
- "正在删除设备……"
+ "正在移除设备…"
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt
index 9082f20a55..f906d0d6ba 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenServiceTest.kt
@@ -14,9 +14,12 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricAuthentica
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.pin.SECRET_KEY_ALIAS
import io.element.android.features.lockscreen.impl.pin.createDefaultPinCodeManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.cryptography.api.SecretKeyRepository
+import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.services.appnavstate.api.AppForegroundStateService
@@ -38,18 +41,18 @@ class DefaultLockScreenServiceTest {
@Test
fun `when the pin is mandatory, isSetupRequired emits true`() = runTest {
- val lockScreenStore = InMemoryLockScreenStore()
+ val secretKeyRepository = SimpleSecretKeyRepository()
val sut = createDefaultLockScreenService(
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
- lockScreenStore = lockScreenStore,
+ secretKeyRepository = secretKeyRepository,
)
sut.isSetupRequired().test {
assertThat(awaitItem()).isTrue()
// When the user configures the pin code, the setup is not required anymore
- lockScreenStore.saveEncryptedPinCode("encryptedCode")
+ secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, true)
assertThat(awaitItem()).isFalse()
// Users deletes the pin code
- lockScreenStore.deleteEncryptedPinCode()
+ secretKeyRepository.deleteKey("elementx.SECRET_KEY_ALIAS_PIN_CODE")
assertThat(awaitItem()).isTrue()
}
}
@@ -57,16 +60,16 @@ class DefaultLockScreenServiceTest {
@Test
fun `when the last session is deleted, the pin code is removed`() = runTest {
val sessionObserver = FakeSessionObserver()
- val lockScreenStore = InMemoryLockScreenStore()
+ val secretKeyRepository = SimpleSecretKeyRepository()
val sut = createDefaultLockScreenService(
lockScreenConfig = aLockScreenConfig(isPinMandatory = true),
- lockScreenStore = lockScreenStore,
+ secretKeyRepository = secretKeyRepository,
sessionObserver = sessionObserver,
)
sut.isPinSetup().test {
assertThat(awaitItem()).isFalse()
// When the user configure the pin code, the setup is not required anymore
- lockScreenStore.saveEncryptedPinCode("encryptedCode")
+ secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, true)
assertThat(awaitItem()).isTrue()
sessionObserver.onSessionDeleted("userId", wasLastSession = false)
expectNoEvents()
@@ -79,8 +82,10 @@ class DefaultLockScreenServiceTest {
private fun TestScope.createDefaultLockScreenService(
lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
+ secretKeyRepository: SecretKeyRepository = SimpleSecretKeyRepository(),
pinCodeManager: PinCodeManager = createDefaultPinCodeManager(
lockScreenStore = lockScreenStore,
+ secretKeyRepository = secretKeyRepository,
),
sessionObserver: SessionObserver = FakeSessionObserver(),
appForegroundStateService: AppForegroundStateService = FakeAppForegroundStateService(),
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt
index 073bdc799d..63729f941a 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticator.kt
@@ -12,6 +12,6 @@ class FakeBiometricAuthenticator(
override val isActive: Boolean = false,
private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
) : BiometricAuthenticator {
- override fun setup() = Unit
+ override suspend fun setup() = Unit
override suspend fun authenticate() = authenticateLambda()
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt
index 9e9b892582..0ae8552334 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricAuthenticatorManager.kt
@@ -15,6 +15,7 @@ class FakeBiometricAuthenticatorManager(
override var isDeviceSecured: Boolean = true,
override var hasAvailableAuthenticator: Boolean = false,
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
+ private val disableLambda: suspend () -> Unit = { },
) : BiometricAuthenticatorManager {
override fun addCallback(callback: BiometricAuthenticator.Callback) {
// no-op
@@ -37,4 +38,8 @@ class FakeBiometricAuthenticatorManager(
createBiometricAuthenticator()
}
}
+
+ override suspend fun disable() {
+ disableLambda()
+ }
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt
index 61acf71cdd..312a33b7f1 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt
@@ -15,12 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
private const val DEFAULT_REMAINING_ATTEMPTS = 3
class InMemoryLockScreenStore : LockScreenStore {
- private val hasPinCode = MutableStateFlow(false)
private var pinCode: String? = null
- set(value) {
- field = value
- hasPinCode.value = value != null
- }
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
private var isBiometricUnlockAllowed = MutableStateFlow(false)
@@ -48,10 +43,6 @@ class InMemoryLockScreenStore : LockScreenStore {
pinCode = null
}
- override fun hasPinCode(): Flow {
- return hasPinCode
- }
-
override fun isBiometricUnlockAllowed(): Flow {
return isBiometricUnlockAllowed
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
index ef3e94f27f..85eb4a37e7 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
@@ -43,19 +43,19 @@ class LockScreenSettingsPresenterTest {
consumeItemsUntilPredicate { state ->
state.showRemovePinOption
}.last().also { state ->
- state.eventSink(LockScreenSettingsEvents.OnRemovePin)
+ state.eventSink(LockScreenSettingsEvent.OnRemovePin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showRemovePinConfirmation).isTrue()
- state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
+ state.eventSink(LockScreenSettingsEvent.CancelRemovePin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showRemovePinConfirmation).isFalse()
- state.eventSink(LockScreenSettingsEvents.OnRemovePin)
+ state.eventSink(LockScreenSettingsEvent.OnRemovePin)
}
awaitLastSequentialItem().also { state ->
assertThat(state.showRemovePinConfirmation).isTrue()
- state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
+ state.eventSink(LockScreenSettingsEvent.ConfirmRemovePin)
}
consumeItemsUntilPredicate {
it.showRemovePinOption.not()
@@ -93,7 +93,7 @@ class LockScreenSettingsPresenterTest {
presenter.test {
skipItems(1)
awaitItem().also { state ->
- state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
}
awaitItem().also { state ->
assertThat(state.isBiometricEnabled).isTrue()
@@ -114,7 +114,7 @@ class LockScreenSettingsPresenterTest {
presenter.test {
skipItems(1)
awaitItem().also { state ->
- state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
}
}
}
@@ -137,7 +137,7 @@ class LockScreenSettingsPresenterTest {
skipItems(1)
awaitItem().also { state ->
assertThat(state.isBiometricEnabled).isTrue()
- state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ state.eventSink(LockScreenSettingsEvent.ToggleBiometricAllowed)
}
awaitItem().also { state ->
assertThat(state.isBiometricEnabled).isFalse()
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
index 3f87c1dccf..9dde220906 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.lockscreen.impl.setup.biometric
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
@@ -18,6 +15,7 @@ import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthen
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -30,12 +28,10 @@ class SetupBiometricPresenterTest {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Success })
})
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isFalse()
- state.eventSink(SetupBiometricEvents.AllowBiometric)
+ state.eventSink(SetupBiometricEvent.AllowBiometric)
}
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isTrue()
@@ -51,12 +47,10 @@ class SetupBiometricPresenterTest {
FakeBiometricAuthenticator(authenticateLambda = { BiometricAuthenticator.AuthenticationResult.Failure() })
})
val presenter = createSetupBiometricPresenter(lockScreenStore, fakeBiometricAuthenticatorManager)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isFalse()
- state.eventSink(SetupBiometricEvents.AllowBiometric)
+ state.eventSink(SetupBiometricEvent.AllowBiometric)
}
}
assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
@@ -66,12 +60,10 @@ class SetupBiometricPresenterTest {
fun `present - skip flow`() = runTest {
val lockScreenStore = InMemoryLockScreenStore()
val presenter = createSetupBiometricPresenter(lockScreenStore)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isFalse()
- state.eventSink(SetupBiometricEvents.UsePin)
+ state.eventSink(SetupBiometricEvent.UsePin)
}
awaitItem().also { state ->
assertThat(state.isBiometricSetupDone).isTrue()
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
index 6a1d32e879..9d63f9e26b 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.lockscreen.impl.setup.pin
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.LockScreenConfig
import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
@@ -24,6 +21,7 @@ import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPin
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -43,9 +41,7 @@ class SetupPinPresenterTest {
}
}
val presenter = createSetupPinPresenter(callback)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().also { state ->
state.choosePinEntry.assertEmpty()
state.confirmPinEntry.assertEmpty()
@@ -63,7 +59,7 @@ class SetupPinPresenterTest {
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(forbiddenPin)
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.ForbiddenPin)
- state.eventSink(SetupPinEvents.ClearFailure)
+ state.eventSink(SetupPinEvent.ClearFailure)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty()
@@ -82,7 +78,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertText(mismatchedPin)
assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDoNotMatch)
- state.eventSink(SetupPinEvents.ClearFailure)
+ state.eventSink(SetupPinEvent.ClearFailure)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty()
@@ -108,7 +104,7 @@ class SetupPinPresenterTest {
}
private fun SetupPinState.onPinEntryChanged(pinEntry: String) {
- eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
+ eventSink(SetupPinEvent.OnPinEntryChanged(pinEntry, isConfirmationStep))
}
private fun createSetupPinPresenter(
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
index f5bfb11818..fa7d05b5cb 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
@@ -8,9 +8,6 @@
package io.element.android.features.lockscreen.impl.unlock
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
@@ -19,12 +16,14 @@ import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCall
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
import io.element.android.features.lockscreen.impl.pin.model.PinEntry
import io.element.android.features.lockscreen.impl.pin.model.assertText
+import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -36,9 +35,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - success verify flow`() = runTest {
val presenter = createPinUnlockPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
awaitItem().also { state ->
assertThat(state.pinEntry).isInstanceOf(AsyncData.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).isFalse()
@@ -50,17 +47,17 @@ class PinUnlockPresenterTest {
awaitItem().also { state ->
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('1')))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('2')))
}
skipItems(1)
awaitItem().also { state ->
state.pinEntry.assertText(halfCompletePin)
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Back))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Empty))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('5')))
}
skipItems(4)
awaitItem().also { state ->
@@ -73,9 +70,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - failure verify flow`() = runTest {
val presenter = createPinUnlockPresenter()
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
val initialState = awaitItem().also { state ->
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
@@ -83,10 +78,10 @@ class PinUnlockPresenterTest {
}
val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
repeat(numberOfAttempts) {
- initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
- initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
- initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
- initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4')))
+ initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('1')))
+ initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('2')))
+ initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('3')))
+ initialState.eventSink(PinUnlockEvent.OnPinKeypadPressed(PinKeypadModel.Number('4')))
}
skipItems(4 * numberOfAttempts + 2)
awaitItem().also { state ->
@@ -102,27 +97,25 @@ class PinUnlockPresenterTest {
val signOutLambda = lambdaRecorder {}
val signOut = FakeLogoutUseCase(signOutLambda)
val presenter = createPinUnlockPresenter(logoutUseCase = signOut)
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
+ presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.pinEntry).isInstanceOf(AsyncData.Success::class.java)
assertThat(state.remainingAttempts).isInstanceOf(AsyncData.Success::class.java)
- state.eventSink(PinUnlockEvents.OnForgetPin)
+ state.eventSink(PinUnlockEvent.OnForgetPin)
}
awaitItem().also { state ->
assertThat(state.showSignOutPrompt).isTrue()
assertThat(state.isSignOutPromptCancellable).isTrue()
- state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
+ state.eventSink(PinUnlockEvent.ClearSignOutPrompt)
}
awaitItem().also { state ->
assertThat(state.showSignOutPrompt).isFalse()
- state.eventSink(PinUnlockEvents.OnForgetPin)
+ state.eventSink(PinUnlockEvent.OnForgetPin)
}
awaitItem().also { state ->
assertThat(state.showSignOutPrompt).isTrue()
- state.eventSink(PinUnlockEvents.SignOut)
+ state.eventSink(PinUnlockEvent.SignOut)
}
skipItems(2)
awaitItem().also { state ->
@@ -132,6 +125,28 @@ class PinUnlockPresenterTest {
}
}
+ @Test
+ fun `present - pin is configured, but deleted in store, sign out prompt will be shown`() = runTest {
+ val lockScreenStore = InMemoryLockScreenStore()
+ val pinCodeManager = aPinCodeManager(
+ lockScreenStore = lockScreenStore,
+ )
+ val presenter = createPinUnlockPresenter(
+ pinCodeManager = pinCodeManager,
+ )
+ // Delete the pin code from the store
+ lockScreenStore.deleteEncryptedPinCode()
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.pinEntry).isInstanceOf(AsyncData.Failure::class.java)
+ assertThat(state.showSignOutPrompt).isTrue()
+ assertThat(state.isSignOutPromptCancellable).isFalse()
+ assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(3)
+ }
+ }
+ }
+
private fun AsyncData.assertText(text: String) {
dataOrNull()?.assertText(text)
}
@@ -139,9 +154,10 @@ class PinUnlockPresenterTest {
private suspend fun TestScope.createPinUnlockPresenter(
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
- logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = { "" }),
+ logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
+ pinCodeManager: PinCodeManager = aPinCodeManager()
): PinUnlockPresenter {
- val pinCodeManager = aPinCodeManager().apply {
+ pinCodeManager.apply {
addCallback(callback)
createPinCode(completePin)
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
index 1ecb79bd67..e6d1659778 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt
@@ -6,60 +6,57 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.lockscreen.impl.unlock.keypad
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isRoot
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PinKeypadTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on a number emits the expected event`() {
+ fun `clicking on a number emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setPinKeyPad(onClick = eventsRecorder)
- rule.onNode(hasText("1")).performClick()
+ setPinKeyPad(onClick = eventsRecorder)
+ onNode(hasText("1")).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
}
@Test
- fun `clicking on the delete previous character button emits the expected event`() {
+ fun `clicking on the delete previous character button emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setPinKeyPad(onClick = eventsRecorder)
- rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick()
+ setPinKeyPad(onClick = eventsRecorder)
+ onNode(hasContentDescription(activity!!.getString(CommonStrings.a11y_delete))).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Back)
}
@OptIn(ExperimentalTestApi::class)
@Test
- fun `typing using the hardware keyboard emits the expected events`() {
+ fun `typing using the hardware keyboard emits the expected events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setPinKeyPad(onClick = eventsRecorder)
- rule.onNodeWithText("1").requestFocus()
- rule.onAllNodes(isRoot())[0].performKeyInput {
+ setPinKeyPad(onClick = eventsRecorder)
+ onNodeWithText("1").requestFocus()
+ onAllNodes(isRoot())[0].performKeyInput {
val keys = listOf(
Key.A,
Key.NumPad1,
@@ -118,7 +115,7 @@ class PinKeypadTest {
)
}
- private fun AndroidComposeTestRule.setPinKeyPad(
+ private fun AndroidComposeUiTest.setPinKeyPad(
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts
index 12af922cbe..86a8e0e7bc 100644
--- a/features/login/impl/build.gradle.kts
+++ b/features/login/impl/build.gradle.kts
@@ -60,7 +60,6 @@ dependencies {
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)
@@ -69,7 +68,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.qrcode)
- implementation(projects.libraries.oidc.api)
+ implementation(projects.libraries.oauth.api)
implementation(projects.libraries.uiUtils)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.browser)
@@ -81,9 +80,8 @@ dependencies {
testImplementation(projects.features.login.test)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.preferences.test)
- testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
- testImplementation(projects.libraries.oidc.test)
+ testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
index fb384d505a..978d28dfa3 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt
@@ -50,9 +50,9 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.annotations.AppCoroutineScope
-import io.element.android.libraries.matrix.api.auth.OidcDetails
-import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.api.OidcActionFlow
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
+import io.element.android.libraries.oauth.api.OAuthAction
+import io.element.android.libraries.oauth.api.OAuthActionFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -64,7 +64,7 @@ class LoginFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val accountProviderDataSource: AccountProviderDataSource,
- private val oidcActionFlow: OidcActionFlow,
+ private val oAuthActionFlow: OAuthActionFlow,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val elementClassicConnection: ElementClassicConnection,
@@ -100,7 +100,7 @@ class LoginFlowNode(
// by pressing back or by closing the Custom Chrome Tab.
lifecycleScope.launch {
delay(5000)
- oidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
+ oAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
}
}
}
@@ -161,8 +161,8 @@ class LoginFlowNode(
backstack.push(NavTarget.LoginPassword())
}
- override fun navigateToOidc(oidcDetails: OidcDetails) {
- navigateToMas(oidcDetails)
+ override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
+ navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
@@ -197,8 +197,8 @@ class LoginFlowNode(
callback.navigateToBugReport()
}
- override fun navigateToOidc(oidcDetails: OidcDetails) {
- navigateToMas(oidcDetails)
+ override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
+ navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
@@ -243,8 +243,8 @@ class LoginFlowNode(
}
NavTarget.ChooseAccountProvider -> {
val callback = object : ChooseAccountProviderNode.Callback {
- override fun navigateToOidc(oidcDetails: OidcDetails) {
- navigateToMas(oidcDetails)
+ override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
+ navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
@@ -270,8 +270,8 @@ class LoginFlowNode(
isAccountCreation = navTarget.isAccountCreation,
)
val callback = object : ConfirmAccountProviderNode.Callback {
- override fun navigateToOidc(oidcDetails: OidcDetails) {
- navigateToMas(oidcDetails)
+ override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
+ navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
@@ -333,10 +333,10 @@ class LoginFlowNode(
}
}
- private fun navigateToMas(oidcDetails: OidcDetails) {
+ private fun navigateToMas(oAuthDetails: OAuthDetails) {
activity?.let {
externalAppStarted = true
- it.openUrlInChromeCustomTab(null, darkTheme, oidcDetails.url)
+ it.openUrlInChromeCustomTab(null, darkTheme, oAuthDetails.url)
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt
index c928c05239..dfddd1d496 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt
@@ -28,8 +28,6 @@ import io.element.android.libraries.androidutils.service.ServiceBinder
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.annotations.AppCoroutineScope
-import io.element.android.libraries.featureflag.api.FeatureFlagService
-import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -71,7 +69,6 @@ class DefaultElementClassicConnection(
private val coroutineScope: CoroutineScope,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker,
- private val featureFlagService: FeatureFlagService,
) : ElementClassicConnection {
// Messenger for communicating with the service.
private var messenger: Messenger? = null
@@ -119,10 +116,6 @@ class DefaultElementClassicConnection(
override fun start() {
Timber.tag(loggerTag.value).d("start()")
coroutineScope.launch {
- if (!featureFlagService.isFeatureEnabled(FeatureFlags.SignInWithClassic)) {
- Timber.tag(loggerTag.value).d("Login with Element Classic is disabled, not starting connection")
- return@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.
@@ -158,11 +151,6 @@ class DefaultElementClassicConnection(
override fun requestSession() {
Timber.tag(loggerTag.value).d("requestSession()")
coroutineScope.launch {
- if (!featureFlagService.isFeatureEnabled(FeatureFlags.SignInWithClassic)) {
- Timber.tag(loggerTag.value).d("Login with Element Classic is disabled")
- emitState(ElementClassicConnectionState.Error("The feature is disabled"))
- return@launch
- }
val finalMessenger = messenger
if (finalMessenger == null) {
Timber.tag(loggerTag.value).d("The messenger is null, can't request data")
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt
index 2f4af14237..560e6123c1 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/error/ChangeServerError.kt
@@ -41,7 +41,7 @@ sealed class ChangeServerError : Exception() {
// AccountAlreadyLoggedIn error should not happen at this point
is AuthenticationException.AccountAlreadyLoggedIn -> Error(messageStr = error.message)
is AuthenticationException.Generic -> Error(messageStr = error.message)
- is AuthenticationException.Oidc -> Error(messageStr = error.message)
+ is AuthenticationException.OAuth -> Error(messageStr = error.message)
}
}
is AccountProviderAccessException.NeedElementProException -> NeedElementPro(
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt
index 78be770bfc..3c871a8a1d 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginHelper.kt
@@ -23,9 +23,9 @@ import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationR
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
-import io.element.android.libraries.matrix.api.auth.OidcPrompt
-import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.api.OidcActionFlow
+import io.element.android.libraries.matrix.api.auth.OAuthPrompt
+import io.element.android.libraries.oauth.api.OAuthAction
+import io.element.android.libraries.oauth.api.OAuthActionFlow
/**
* This class is responsible for managing the login flow, including handling OIDC actions and
@@ -35,7 +35,7 @@ import io.element.android.libraries.oidc.api.OidcActionFlow
*/
@Inject
class LoginHelper(
- private val oidcActionFlow: OidcActionFlow,
+ private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
) {
@@ -44,9 +44,9 @@ class LoginHelper(
@Composable
fun collectLoginMode(): State> {
LaunchedEffect(Unit) {
- oidcActionFlow.collect { oidcAction ->
- if (oidcAction != null) {
- onOidcAction(oidcAction)
+ oAuthActionFlow.collect { oAuthAction ->
+ if (oAuthAction != null) {
+ onOAuthAction(oAuthAction)
}
}
}
@@ -73,11 +73,11 @@ class LoginHelper(
throw it
}
}.map { matrixHomeServerDetails ->
- if (matrixHomeServerDetails.supportsOidcLogin) {
+ if (matrixHomeServerDetails.supportsOAuthLogin) {
// Retrieve the details right now
- val oidcPrompt = if (isAccountCreation) OidcPrompt.Create else OidcPrompt.Login
- LoginMode.Oidc(
- authenticationService.getOidcUrl(prompt = oidcPrompt, loginHint = loginHint).getOrThrow()
+ val oAuthPrompt = if (isAccountCreation) OAuthPrompt.Create else OAuthPrompt.Login
+ LoginMode.OAuth(
+ authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = loginHint).getOrThrow()
)
} else if (isAccountCreation) {
val url = webClientUrlForAuthenticationRetriever.retrieve(homeserverUrl)
@@ -99,16 +99,16 @@ class LoginHelper(
)
}
- private suspend fun onOidcAction(oidcAction: OidcAction) {
- if (oidcAction is OidcAction.GoBack && oidcAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
+ private suspend fun onOAuthAction(oAuthAction: OAuthAction) {
+ if (oAuthAction is OAuthAction.GoBack && oAuthAction.toUnblock && loginModeState.value !is AsyncData.Loading) {
// Ignore GoBack action if the current state is not Loading. This GoBack action is coming from LoginFlowNode.
// This can happen if there is an error, for instance attempt to login again on the same account.
return
}
loginModeState.value = AsyncData.Loading()
- when (oidcAction) {
- is OidcAction.GoBack -> {
- authenticationService.cancelOidcLogin()
+ when (oAuthAction) {
+ is OAuthAction.GoBack -> {
+ authenticationService.cancelOAuthLogin()
.onSuccess {
loginModeState.value = AsyncData.Uninitialized
}
@@ -116,13 +116,13 @@ class LoginHelper(
loginModeState.value = AsyncData.Failure(failure)
}
}
- is OidcAction.Success -> {
- authenticationService.loginWithOidc(oidcAction.url)
+ is OAuthAction.Success -> {
+ authenticationService.loginWithOAuth(oAuthAction.url)
.onFailure { failure ->
loginModeState.value = AsyncData.Failure(failure)
}
}
}
- oidcActionFlow.reset()
+ oAuthActionFlow.reset()
}
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt
index 08e604ef20..5ea52e0ebd 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginMode.kt
@@ -8,10 +8,10 @@
package io.element.android.features.login.impl.login
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
sealed interface LoginMode {
data object PasswordLogin : LoginMode
- data class Oidc(val oidcDetails: OidcDetails) : LoginMode
+ data class OAuth(val oAuthDetails: OAuthDetails) : LoginMode
data class AccountCreation(val url: String) : LoginMode
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt
index f88e34bf4a..3549e17457 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt
@@ -24,7 +24,7 @@ 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.matrix.api.auth.AuthenticationException
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -32,7 +32,7 @@ fun LoginModeView(
loginMode: AsyncData,
onClearError: () -> Unit,
onLearnMoreClick: () -> Unit,
- onOidcDetails: (OidcDetails) -> Unit,
+ onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit
) {
@@ -118,7 +118,7 @@ fun LoginModeView(
is AsyncData.Loading -> Unit // The Continue button shows the loading state
is AsyncData.Success -> {
when (val loginModeData = loginMode.data) {
- is LoginMode.Oidc -> onOidcDetails(loginModeData.oidcDetails)
+ is LoginMode.OAuth -> onOAuthDetails(loginModeData.oAuthDetails)
LoginMode.PasswordLogin -> onNeedLoginPassword()
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
}
@@ -137,7 +137,7 @@ internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::
loginMode = AsyncData.Failure(error),
onClearError = {},
onLearnMoreClick = {},
- onOidcDetails = {},
+ onOAuthDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {}
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt
index 613aa6aeb6..03264551a5 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt
@@ -135,8 +135,8 @@ class QrCodeLoginFlowNode(
is QrLoginException.SlidingSyncNotAvailable -> {
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable))
}
- is QrLoginException.OidcMetadataInvalid -> {
- Timber.e(error, "OIDC metadata is invalid")
+ is QrLoginException.OAuthMetadataInvalid -> {
+ Timber.e(error, "OAuth metadata is invalid")
backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError))
}
QrLoginException.CheckCodeAlreadySent,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt
index 5dc6ebbd6b..5f79f197d9 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt
@@ -20,7 +20,7 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.callback
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
@ContributesNode(AppScope::class)
@AssistedInject
@@ -31,7 +31,7 @@ class ChooseAccountProviderNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToLoginPassword()
- fun navigateToOidc(oidcDetails: OidcDetails)
+ fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
}
@@ -45,7 +45,7 @@ class ChooseAccountProviderNode(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
- onOidcDetails = callback::navigateToOidc,
+ onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt
index cdb80304a7..f05606dbc3 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt
@@ -43,14 +43,14 @@ 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.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ChooseAccountProviderView(
state: ChooseAccountProviderState,
onBackClick: () -> Unit,
- onOidcDetails: (OidcDetails) -> Unit,
+ onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
@@ -129,7 +129,7 @@ fun ChooseAccountProviderView(
state.eventSink(ChooseAccountProviderEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
- onOidcDetails = onOidcDetails,
+ onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
@@ -144,7 +144,7 @@ internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountPro
state = state,
onBackClick = { },
onLearnMoreClick = { },
- onOidcDetails = { },
+ onOAuthDetails = { },
onNeedLoginPassword = { },
onCreateAccountContinue = { },
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt
index f2ff998652..cfbd86f363 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt
@@ -31,7 +31,7 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberFaderOrSliderTransitionHandler
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -54,7 +54,7 @@ class ClassicFlowNode(
interface Callback : Plugin {
fun navigateToOnBoarding(allowBackNavigation: Boolean)
fun navigateToLoginPassword()
- fun navigateToOidc(oidcDetails: OidcDetails)
+ fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
}
@@ -111,8 +111,8 @@ class ClassicFlowNode(
callback.navigateToLoginPassword()
}
- override fun navigateToOidc(oidcDetails: OidcDetails) {
- callback.navigateToOidc(oidcDetails)
+ override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
+ callback.navigateToOAuth(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt
index c42248a3f8..d5acca38ae 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt
@@ -21,7 +21,7 @@ import io.element.android.features.login.impl.util.openLearnMorePage
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.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.api.core.UserId
@ContributesNode(AppScope::class)
@@ -35,7 +35,7 @@ class LoginWithClassicNode(
interface Callback : Plugin {
fun navigateToOtherOptions()
fun navigateToLoginPassword()
- fun navigateToOidc(oidcDetails: OidcDetails)
+ fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
fun navigateToMissingKeyBackup()
}
@@ -60,7 +60,7 @@ class LoginWithClassicNode(
state = state,
modifier = modifier,
onOtherOptionsClick = callback::navigateToOtherOptions,
- onOidcDetails = callback::navigateToOidc,
+ onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt
index d8dcfeb072..31b1770f63 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicStateProvider.kt
@@ -12,13 +12,14 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
open class LoginWithClassicStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aLoginWithClassicState(),
- aLoginWithClassicState(isElementPro = true, displayName = "Alice"),
+ aLoginWithClassicState(isElementPro = true, displayName = USER_NAME_ALICE),
)
}
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt
index 6b5c48f1ec..b1ca50fe61 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt
@@ -49,7 +49,7 @@ 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.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -59,7 +59,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LoginWithClassicView(
state: LoginWithClassicState,
onOtherOptionsClick: () -> Unit,
- onOidcDetails: (OidcDetails) -> Unit,
+ onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
@@ -200,7 +200,7 @@ fun LoginWithClassicView(
state.eventSink(LoginWithClassicEvent.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
- onOidcDetails = onOidcDetails,
+ onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
@@ -212,7 +212,7 @@ internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicState
LoginWithClassicView(
state = state,
onOtherOptionsClick = {},
- onOidcDetails = {},
+ onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onCreateAccountContinue = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
index e3643afbf2..928a493dc1 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt
@@ -22,7 +22,7 @@ import io.element.android.features.login.impl.util.openLearnMorePage
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.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
@ContributesNode(AppScope::class)
@AssistedInject
@@ -44,7 +44,7 @@ class ConfirmAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
- fun navigateToOidc(oidcDetails: OidcDetails)
+ fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
fun navigateToChangeAccountProvider()
}
@@ -58,7 +58,7 @@ class ConfirmAccountProviderNode(
ConfirmAccountProviderView(
state = state,
modifier = modifier,
- onOidcDetails = callback::navigateToOidc,
+ onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onCreateAccountContinue = callback::navigateToCreateAccount,
onChange = callback::navigateToChangeAccountProvider,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
index a175ab556d..c2525f3756 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt
@@ -30,7 +30,7 @@ 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.TextButton
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -38,7 +38,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
- onOidcDetails: (OidcDetails) -> Unit,
+ onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
@@ -103,7 +103,7 @@ fun ConfirmAccountProviderView(
eventSink(ConfirmAccountProviderEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
- onOidcDetails = onOidcDetails,
+ onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
@@ -117,7 +117,7 @@ internal fun ConfirmAccountProviderViewPreview(
) = ElementPreview {
ConfirmAccountProviderView(
state = state,
- onOidcDetails = {},
+ onOAuthDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {},
onLearnMoreClick = {},
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt
index 5572c412a0..99f7e86fd3 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt
@@ -22,7 +22,7 @@ import io.element.android.features.login.impl.util.openLearnMorePage
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.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
@ContributesNode(AppScope::class)
@AssistedInject
@@ -40,7 +40,7 @@ class OnBoardingNode(
fun navigateToQrCode()
fun navigateToBugReport()
fun navigateToLoginPassword()
- fun navigateToOidc(oidcDetails: OidcDetails)
+ fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
fun navigateToDeveloperSettings()
fun onDone()
@@ -71,7 +71,7 @@ class OnBoardingNode(
onCreateAccount = callback::navigateToSignUpFlow,
onSignInWithQrCode = callback::navigateToQrCode,
onReportProblem = callback::navigateToBugReport,
- onOidcDetails = callback::navigateToOidc,
+ onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
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 5ee7ab6ac4..53c36ac4f8 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
@@ -50,7 +50,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -68,7 +68,7 @@ fun OnBoardingView(
onSignInWithQrCode: () -> Unit,
onSignIn: (mustChooseAccountProvider: Boolean) -> Unit,
onCreateAccount: () -> Unit,
- onOidcDetails: (OidcDetails) -> Unit,
+ onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
@@ -82,7 +82,7 @@ fun OnBoardingView(
state.eventSink(OnBoardingEvents.ClearError)
},
onLearnMoreClick = onLearnMoreClick,
- onOidcDetails = onOidcDetails,
+ onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
@@ -354,7 +354,7 @@ internal fun OnBoardingViewPreview(
onSignIn = {},
onCreateAccount = {},
onReportProblem = {},
- onOidcDetails = {},
+ onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onCreateAccountContinue = {},
diff --git a/features/login/impl/src/main/res/values-be/translations.xml b/features/login/impl/src/main/res/values-be/translations.xml
index 307ccf7263..d9d69503c5 100644
--- a/features/login/impl/src/main/res/values-be/translations.xml
+++ b/features/login/impl/src/main/res/values-be/translations.xml
@@ -24,7 +24,7 @@
"Няправільнае імя карыстальніка і/або пароль""Гэта несапраўдны ідэнтыфікатар карыстальніка. Чаканы фармат: ‘@user:homeserver.org’""Гэты сервер настроены на выкарыстанне маркераў абнаўлення. Яны не падтрымліваюцца пры ўваходзе на аснове пароля."
- "Выбраны хатні сервер не падтрымлівае пароль або ўваход у OIDC. Калі ласка, звярніцеся да адміністратара або абярыце іншы хатні сервер."
+ "Выбраны хатні сервер не падтрымлівае пароль або ўваход у OAuth. Калі ласка, звярніцеся да адміністратара або абярыце іншы хатні сервер.""Увядзіце свае даныя""Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі.""Сардэчна запрашаем!"
diff --git a/features/login/impl/src/main/res/values-bg/translations.xml b/features/login/impl/src/main/res/values-bg/translations.xml
index 6ccc7c9129..c6c74ff1a8 100644
--- a/features/login/impl/src/main/res/values-bg/translations.xml
+++ b/features/login/impl/src/main/res/values-bg/translations.xml
@@ -21,7 +21,7 @@
"Този акаунт бе деактивиран.""Неправилно потребителско име и/или парола""Това не е валиден потребителски идентификатор. Очакван формат: ‘@user:homeserver.org’"
- "Избраният сървър не поддържа влизане с парола или OIDC. Моля, свържете се с вашия администратор или изберете друг сървър."
+ "Избраният сървър не поддържа влизане с парола или OAuth. Моля, свържете се с вашия администратор или изберете друг сървър.""Въведете своите данни""Matrix е отворена мрежа за сигурна, децентрализирана комуникация.""Добре дошли отново!"
diff --git a/features/login/impl/src/main/res/values-ca/translations.xml b/features/login/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..f079e7b071
--- /dev/null
+++ b/features/login/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,89 @@
+
+
+ "Canvia el proveïdor del compte"
+ "Adreça del servidor"
+ "Introdueix una paraula de cerca o un domini (adreça)."
+ "Cerca una empresa, una comunitat o un servidor privat."
+ "Busca un proveïdor de comptes"
+ "Aquí és on es guardaran els teus xats, de manera similar a utilitzar un proveïdor de correu electrònic per guardar els teus correus electrònics."
+ "Estàs a punt d\'iniciar sessió a %s"
+ "Aquí és on es guardaran els teus xats, de manera similar a utilitzar un proveïdor de correu electrònic per guardar els teus correus electrònics."
+ "Estàs a punt de crear un compte a %s"
+ "Matrix.org és un gran servidor gratuït de la xarxa pública de Matrix per a la comunicació segura i descentralitzada, gestionat per la Fundació Matrix.org."
+ "Altres"
+ "Utilitza un proveïdor de comptes diferent, com ara el teu servidor privat o un compte de feina."
+ "Canvia el proveïdor del compte"
+ "No s\'ha pogut accedir a aquest servidor. Comprova que hagis introduït correctament l\'URL del servidor. Si ja és correcte, posa\'t en contacte amb l\'administrador del servidor per a més informació."
+ "Servidor no disponible a causa d\'un problema al fitxer .well-known:
+%1$s"
+ "URL del servidor"
+ "Introdueix un domini."
+ "Quina és l\'adreça del teu servidor?"
+ "Selecciona el teu servidor"
+ "Crea un compte"
+ "Aquest compte s\'ha desactivat."
+ "Usuari i/o contrasenya incorrectes"
+ "Identificador d\'usuari invàlid. Format esperat: ‘@usuari:servidor.org’"
+ "Aquest servidor està configurat per utilitzar tokens d\'actualització, però no són compatibles quan s\'utilitza l\'inici de sessió basat en contrasenya."
+ "El servidor seleccionat no admet contrasenya o inici de sessió OAuth. Posa\'t en contacte amb l\'administrador o tria un altre servidor."
+ "Introdueix les teves dades"
+ "Matrix és una xarxa oberta per a comunicacions segures i descentralitzades."
+ "Hola de nou!"
+ "Inicia sessió a %1$s"
+ "Inicia sessió manualment"
+ "Inicia sessió a %1$s"
+ "Inicia sessió amb un codi QR"
+ "Crea un compte"
+ "Et donem la benvinguda a %1$s. Més ràpid i simple que mai."
+ "Et donem la benvinguda a %1$s. Dissenyat per ser més ràpid i simple."
+ "Sigues el teu propi element"
+ "Establint una connexió segura"
+ "No s\'ha pogut establir una connexió segura amb el dispositiu nou. Els dispositius existents continuen sent segurs, no te n\'has de preocupar."
+ "I ara què?"
+ "Prova de tornar a iniciar sessió mitjançant un codi QR si es tracta d\'un problema de xarxa."
+ "Si es repeteix el mateix problema, prova una xarxa wifi diferent o utilitza les dades mòbils en lloc del wifi."
+ "Si no funciona, inicia sessió manualment"
+ "Connexió no segura"
+ "Se\'t demanarà que introdueixis els dos dígits mostrats en aquest dispositiu."
+ "Introdueix el número següent a l\'altre dispositiu"
+ "Inicia sessió a l\'altre dispositiu i torna-ho a provar o utilitza un altre dispositiu amb la sessió ja iniciada."
+ "No s\'ha iniciat sessió a l\'altre dispositiu"
+ "L\'inici de sessió s\'ha cancel·lat a l\'altre dispositiu."
+ "Sol·licitud d\'inici de sessió cancel·lada"
+ "L\'inici de sessió s\'ha rebutjat a l\'altre dispositiu."
+ "Inici de sessió rebutjat"
+ "Inici de sessió ha caducat. Torna-ho a provar."
+ "L\'inici de sessió no s\'ha completat a temps"
+ "El teu altre dispositiu no admet l\'inici de sessió a %s amb codis QR.
+
+Prova d\'iniciar la sessió manualment o escaneja el QR amb un altre dispositiu."
+ "Codi QR no compatible"
+ "El proveïdor del teu compte no admet %1$s."
+ "%1$s no és compatible"
+ "Preparat per escanejar"
+ "Obre %1$s en un dispositiu d\'escriptori"
+ "Clica la teva imatge"
+ "Selecciona %1$s"
+ "“Enllaça nou dispositiu”"
+ "Escaneja el codi QR amb aquest dispositiu"
+ "Només disponible si el proveïdor del compte ho admet."
+ "Obre %1$s en un altre dispositiu per obtenir el codi QR"
+ "Utilitza el codi QR que es mostra a l\'altre dispositiu."
+ "Torna-ho a intentar"
+ "Codi QR incorrecte"
+ "Vés a la configuració de càmera"
+ "Per continuar, has donar permís a %1$s per poder utilitzar la càmera del dispositiu."
+ "Permet l\'accés a la càmera per poder escanejar el codi QR"
+ "Escaneja el QR"
+ "Torna a començar"
+ "S\'ha produït un error inesperat. Torna-ho a provar."
+ "Esperant el teu altre dispositiu"
+ "Per verificar l\'inici de sessió, pot ser que el proveïdor del teu compte et demani el següent codi."
+ "Codi de verificació"
+ "Canvia el proveïdor del compte"
+ "Servidor privat per a treballadors d\'Element."
+ "Matrix és una xarxa oberta per a comunicacions segures i descentralitzades."
+ "Aquí és on es guardaran els teus xats, de manera similar a utilitzar un proveïdor de correu electrònic per guardar els teus correus electrònics."
+ "Estàs a punt d\'iniciar sessió a %1$s"
+ "Estàs a punt de crear un compte a %1$s"
+
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 73f0dd51cc..3181e823bb 100644
--- a/features/login/impl/src/main/res/values-cs/translations.xml
+++ b/features/login/impl/src/main/res/values-cs/translations.xml
@@ -28,20 +28,29 @@
"Jaká je adresa vašeho serveru?""Vyberte váš server""Vytvořit účet"
- "Tento účet byl deaktivován."
+ "Tento účet byl smazán.""Nesprávné uživatelské jméno nebo heslo""Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'""Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle."
- "Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OIDC. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."
+ "Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OAuth. Kontaktujte prosím svého správce nebo vyberte jiný domovský server.""Zadejte své údaje""Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci.""Vítejte zpět!""Přihlaste se k %1$s"
+ "Otevřít Element Classic"
+ "Otevřete Element Classic na svém zařízení"
+ "Přejděte do Nastavení > Zabezpečení a soukromí"
+ "V části Správa kryptografických klíčů vyberte Obnova šifrovaných zpráv"
+ "Postupujte podle pokynů k povolení úložiště klíčů"
+ "Vraťte se do %1$s"
+ "Povolte úložiště klíčů, než budete pokračovat na %1$s""Verze %1$s"
+ "Kontrola účtu""Ruční přihlášení""Přihlaste se k %1$s""Přihlásit se pomocí QR kódu""Vytvořit účet"
+ "Vítejte zpět""Vítejte v dosud nejrychlejším %1$su. Vylepšený pro rychlost a jednoduchost.""Vítejte v %1$su. Vylepšený, pro rychlost a jednoduchost.""Buďte ve svém živlu"
diff --git a/features/login/impl/src/main/res/values-cy/translations.xml b/features/login/impl/src/main/res/values-cy/translations.xml
index b8988a9889..0f44287ed4 100644
--- a/features/login/impl/src/main/res/values-cy/translations.xml
+++ b/features/login/impl/src/main/res/values-cy/translations.xml
@@ -32,7 +32,7 @@
"Enw defnyddiwr a/neu gyfrinair anghywir""Nid yw hwn yn ddynodwr defnyddiwr dilys. Fformat disgwyliedig: ‘@user:homeserver.org’""Mae\'r gweinydd hwn wedi\'i ffurfweddu i ddefnyddio tocynnau adnewyddu. Nid yw\'r rhain yn cael eu cefnogi wrth ddefnyddio mewngofnodi ar sail cyfrinair."
- "Nid yw\'r gweinydd cartref ddewiswyd yn cefnogi cyfrinair na mewngofnodi OIDC. Cysylltwch â\'ch gweinyddwr neu dewis gweinydd cartref arall."
+ "Nid yw\'r gweinydd cartref ddewiswyd yn cefnogi cyfrinair na mewngofnodi OAuth. Cysylltwch â\'ch gweinyddwr neu dewis gweinydd cartref arall.""Rhowch eich manylion""Mae Matrix yn rhwydwaith agored ar gyfer cyfathrebu diogel, datganoledig.""Croeso nôl!"
diff --git a/features/login/impl/src/main/res/values-da/translations.xml b/features/login/impl/src/main/res/values-da/translations.xml
index 2b2f00267b..35d66a6e69 100644
--- a/features/login/impl/src/main/res/values-da/translations.xml
+++ b/features/login/impl/src/main/res/values-da/translations.xml
@@ -28,20 +28,29 @@
"Hvad er adressen på din server?""Vælg din server""Opret konto"
- "Denne konto er blevet deaktiveret."
+ "Denne konto er blevet slettet.""Forkert brugernavn og/eller adgangskode""Dette er ikke en gyldig brugeridentifikation. Forventet format: \'@bruger:hjemmeserver.org\'""Denne server er konfigureret til at bruge opdateringstokens. Disse understøttes ikke, når du bruger adgangskodebaseret login."
- "Den valgte hjemmeserver understøtter ikke adgangskode eller OIDC-login. Kontakt venligst din administrator eller vælg en anden hjemmeserver."
+ "Den valgte hjemmeserver understøtter ikke adgangskode eller OAuth-login. Kontakt venligst din administrator eller vælg en anden hjemmeserver.""Indtast dine oplysninger""Matrix er et åbent netværk for sikker, decentraliseret kommunikation.""Velkommen tilbage!""Log ind på %1$s"
+ "Åbn Element Classic"
+ "Åbn Element Classic på din enhed"
+ "Gå til Indstillinger > Sikkerhed og privatliv"
+ "I Nøgleadministration skal du, under Kryptografi, vælge Gendannelse af krypterede meddelelser"
+ "Følg instruktionerne for at aktivere dit nøglelager"
+ "Gå tilbage til %1$s"
+ "Aktivér dit nøglelager, før du fortsætter til %1$s""Version %1$s"
+ "Kontoen kontrolleres…""Log ind manuelt""Log ind på %1$s""Log ind med QR-kode""Opret konto"
+ "Velkommen tilbage""Velkommen til den hurtigste %1$s nogensinde. Supercharged til hastighed og enkelhed.""Velkommen til %1$s. Ladet med hastighed og enkelhed.""Vær i dit rette Element"
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 f1d426e134..89e4412d0f 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -5,9 +5,9 @@
"Gib einen Suchbegriff oder eine Domainadresse ein.""Suche nach einem Unternehmen, einer Community oder einem privaten Server.""Kontoanbieter finden"
- "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden."
+ "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würdest.""Du bist dabei, dich bei %s anzumelden"
- "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden."
+ "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würdest.""Du bist dabei, ein Konto bei %s zu erstellen""Matrix.org ist ein großer, kostenloser Server im öffentlichen Matrix-Netzwerk für eine sichere, dezentralisierte Kommunikation, der von der Matrix.org Foundation betrieben wird.""Sonstige"
@@ -28,11 +28,11 @@
"Wie lautet die Adresse deines Servers?""Wähle deinen Server aus""Konto erstellen"
- "Dieses Konto wurde deaktiviert."
+ "Dieses Konto wurde gelöscht.""Falscher Nutzername und/oder Passwort""Dies ist keine gültige Nutzerkennung. Erwartetes Format: \'@nutzer:homeserver.org\'""Dieser Server ist so konfiguriert, dass er Refresh-Tokens verwendet. Diese werden für die passwortbasierte Anmeldung nicht unterstützt."
- "Der ausgewählte Homeserver unterstützt weder den Login per Passwort noch per OIDC. Bitte kontaktiere deinen Administrator oder wähle einen anderen Homeserver."
+ "Der ausgewählte Homeserver unterstützt weder den Login per Passwort noch per OAuth. Bitte kontaktiere deinen Administrator oder wähle einen anderen Homeserver.""Gib deine Daten ein""Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation.""Willkommen zurück!"
@@ -42,6 +42,7 @@
"Anmelden bei %1$s""Mit QR-Code anmelden""Konto erstellen"
+ "Willkommen zurück""Willkommen beim schnellsten %1$s aller Zeiten. Optimiert für Geschwindigkeit und Einfachheit.""Willkommen zu %1$s. Aufgeladen, für Geschwindigkeit und Einfachheit.""Sei in Deinem Element"
@@ -93,7 +94,7 @@ Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Ger
"Kontoanbieter wechseln""Ein privater Server für die Mitarbeiter von Element.""Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."
- "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würden."
+ "Hier werden deine Gespräche gespeichert - so wie du deine E-Mails bei einem E-Mail-Anbieter aufbewahren würdest.""Du bist dabei, dich bei %1$s anzumelden""Kontoanbieter auswählen""Du bist dabei, auf %1$s ein Konto zu erstellen"
diff --git a/features/login/impl/src/main/res/values-el/translations.xml b/features/login/impl/src/main/res/values-el/translations.xml
index 5f21a53c37..045465902b 100644
--- a/features/login/impl/src/main/res/values-el/translations.xml
+++ b/features/login/impl/src/main/res/values-el/translations.xml
@@ -28,20 +28,28 @@
"Ποια είναι η διεύθυνση του διακομιστή σου;""Επέλεξε το διακομιστή σου""Δημιουργία λογαριασμού"
- "Αυτός ο λογαριασμός έχει απενεργοποιηθεί."
+ "Αυτός ο λογαριασμός έχει διαγραφεί.""Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης""Αυτό δεν είναι έγκυρο αναγνωριστικό χρήστη. Αναμενόμενη μορφή: \'@χρήστης:homeserver.org\'""Αυτός ο διακομιστής έχει ρυθμιστεί ώστε να χρησιμοποιεί διακριτικά ανανέωσης. Αυτά δεν υποστηρίζονται όταν χρησιμοποιείς σύνδεση μέσω κωδικού πρόσβασης."
- "Ο επιλεγμένος οικιακός διακομιστής δεν υποστηρίζει κωδικό πρόσβασης ή σύνδεση OIDC. Επικοινωνήστε με τον διαχειριστή σου ή επέλεξε άλλο οικιακό διακομιστή."
+ "Ο επιλεγμένος οικιακός διακομιστής δεν υποστηρίζει κωδικό πρόσβασης ή σύνδεση OAuth. Επικοινωνήστε με τον διαχειριστή σου ή επέλεξε άλλο οικιακό διακομιστή.""Εισήγαγε τα στοιχεία σου""Το Matrix είναι ένα ανοιχτό δίκτυο για ασφαλή, αποκεντρωμένη επικοινωνία.""Καλωσόρισες ξανά!""Συνδέσου στο %1$s"
+ "Άνοιγμα του Element Classic"
+ "Ανοίξτε το Element Classic στη συσκευή σας."
+ "Μεταβείτε στις Ρυθμίσεις > Ασφάλεια και Απόρρητο"
+ "Στη Διαχείριση κλειδιών κρυπτογράφησης, επιλέξτε Ανάκτηση κρυπτογραφημένων μηνυμάτων"
+ "Ακολουθήστε τις οδηγίες για να ενεργοποιήσετε την αποθήκευση κλειδιών"
+ "Επιστρέψτε στο %1$s"
+ "Ενεργοποιήστε την αποθήκευση κλειδιών σας πριν προχωρήσετε στο %1$s""Έκδοση %1$s""Σύνδεση χειροκίνητα""Συνδέσου στο %1$s""Συνδέσου με κωδικό QR""Δημιουργία λογαριασμού"
+ "Καλώς ήρθατε ξανά""Καλώς ήλθατε στο γρηγορότερο %1$s όλων των εποχών. Υπερτροφοδοτούμενο με ταχύτητα και απλότητα.""Καλώς ήρθες στο %1$s. Υπερφορτισμένο, για ταχύτητα και απλότητα.""Μείνε στο element σου"
diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml
index df6a06ab29..3b9ffbe2dd 100644
--- a/features/login/impl/src/main/res/values-es/translations.xml
+++ b/features/login/impl/src/main/res/values-es/translations.xml
@@ -29,7 +29,7 @@
"Usuario y/o contraseña incorrectos""Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'""Este servidor está configurado para utilizar tokens de actualización. Estos no son compatibles cuando se utiliza el inicio de sesión basado en contraseña."
- "El servidor base seleccionado no admite el inicio de sesión usando contraseña ni OIDC. Ponte en contacto con tu administrador o elige otro servidor base."
+ "El servidor base seleccionado no admite el inicio de sesión usando contraseña ni OAuth. Ponte en contacto con tu administrador o elige otro servidor base.""Introduce tus datos""Matrix es una red abierta para una comunicación segura y descentralizada.""¡Hola de nuevo!"
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 0a1a99383d..dc37ed3e38 100644
--- a/features/login/impl/src/main/res/values-et/translations.xml
+++ b/features/login/impl/src/main/res/values-et/translations.xml
@@ -28,20 +28,29 @@
"Mis on sinu koduserveri aadress?""Vali oma server""Loo kasutajakonto"
- "Konto on kasutusest eemaldatud."
+ "See kasutajakonto on kustutatud.""Vigane kasutajanimi ja/või salasõna""See ei ole korrektne kasutajanimi. Õige vorming on: „@kasutaja:koduserver.ee“""See server on seadistatud kasutama tunnusloa põhist sisselogimist. Salasõnaga sisselogimisel see võimalus aga ei ole toetatud."
- "Valitud koduserver ei toeta salasõna ega OIDC-põhist sisselogimist. Lisateavet saad koduserveri haldajalt, aga sa võid ka valida mõne teise serveri."
+ "Valitud koduserver ei toeta salasõna ega OAuth-põhist sisselogimist. Lisateavet saad koduserveri haldajalt, aga sa võid ka valida mõne teise serveri.""Sisesta oma andmed""Matrix on avatud võrk turvalise ja hajutatud suhtluse jaoks.""Tere tulemast tagasi!""Logi sisse serverisse %1$s"
+ "Ava Element Classic"
+ "Ava Element Classic oma seadmes"
+ "Ava „Seadistused“ → „Turvalisus ja privaatsus“"
+ "Krüptovõtmete halduses vali „Krüptitud sõnumite taastamine“"
+ "Võtmehoidla kasutuselevõtmiseks palun järgi juhendit"
+ "Tule tagasi rakendusse %1$s"
+ "Enne jätkamist rakenduses %1$s võta oma võtmehoidla kasutusele""Versioon %1$s"
+ "Kontrollin kasutajakontot""Logi sisse käsitsi""Logi sisse serverisse %1$s""Logi sisse QR-koodi alusel""Loo kasutajakonto"
+ "Tere tulemast tagasi""Läbi aegade kiireim ja mugavaim %1$s.""Tere tulemast kasutama kiiret ja lihtsat suhtlusrakendust %1$s.""Ole oma elemendis"
diff --git a/features/login/impl/src/main/res/values-eu/translations.xml b/features/login/impl/src/main/res/values-eu/translations.xml
index 355e63546c..78a13e5810 100644
--- a/features/login/impl/src/main/res/values-eu/translations.xml
+++ b/features/login/impl/src/main/res/values-eu/translations.xml
@@ -21,7 +21,7 @@
"Sortu kontua""Kontu hau desaktibatuta dago.""Erabiltzaile-izena edo/eta pasahitza okerrak"
- "Hautatutako zerbitzaria ez da bateragarria pasahitz edo OIDC saio-hasierarekin. Jarri harremanetan administratzailearekin edo aukeratu beste zerbitzari bat."
+ "Hautatutako zerbitzaria ez da bateragarria pasahitz edo OAuth saio-hasierarekin. Jarri harremanetan administratzailearekin edo aukeratu beste zerbitzari bat.""Sartu zure datuak""Matrix komunikazio seguru eta deszentralizaturako sare irekia da.""Ongi etorri!"
diff --git a/features/login/impl/src/main/res/values-fa/translations.xml b/features/login/impl/src/main/res/values-fa/translations.xml
index ef7062a88a..c28c891c9a 100644
--- a/features/login/impl/src/main/res/values-fa/translations.xml
+++ b/features/login/impl/src/main/res/values-fa/translations.xml
@@ -15,15 +15,17 @@
"تغییر فراهم کنندهٔ حساب""پلی گپگل""ما نتوانستیم به این کارساز خانگی برسیم. لطفاً بررسی کنید که URL کارساز اصلی را به درستی وارد کرده اید. اگر URL صحیح است، برای کمک بیشتر با مدیر کارساز خانگی خود تماس بگیرید."
+ "سرور به دلیل مشکلی در فایل .well-known در دسترس نیست: %1$s""نشانی کارساز خانگی""ورود نشانی دامنه.""نشانی کارسازتان چیست؟""کارسازتان را برگزینید""ایجاد حساب"
- "این حساب از کار افتاده است."
+ "این حساب حذف شده است.""نام کاربری یا گذرواژه نامعتبر است""این یک شناسه کاربری معتبر نیست. قالب صحیح: «@user:homeserver.or"
- "کارساز اصلی انتخاب شده از رمز عبور یا ورود OIDC پشتیبانی نمی کند. لطفا با مدیر خود تماس بگیرید یا یک کارساز خانگی دیگر را انتخاب کنید."
+ "این سرور برای استفاده از توکنهای بهروزرسانی پیکربندی شده است. این توکنها هنگام استفاده از ورود مبتنی بر رمز عبور پشتیبانی نمیشوند."
+ "کارساز اصلی انتخاب شده از رمز عبور یا ورود OAuth پشتیبانی نمی کند. لطفا با مدیر خود تماس بگیرید یا یک کارساز خانگی دیگر را انتخاب کنید.""جزییاتتان را وارد کنید""ماتریکس شبکهای بار برای ارتباطات نامتمرکز و امن است.""خوش برگشتید!"
diff --git a/features/login/impl/src/main/res/values-fi/translations.xml b/features/login/impl/src/main/res/values-fi/translations.xml
index af8e242309..8561bcfce3 100644
--- a/features/login/impl/src/main/res/values-fi/translations.xml
+++ b/features/login/impl/src/main/res/values-fi/translations.xml
@@ -28,20 +28,29 @@
"Mikä on palvelimesi osoite?""Valitse palvelimesi""Luo tili"
- "Tämä tili on deaktivoitu."
+ "Tämä tili on poistettu.""Väärä käyttäjänimi ja/tai salasana""Tämä ei ole kelvollinen käyttäjätunnus. Odotettu muoto: \'@käyttäjä:kotipalvelin.fi\'""Tämä palvelin on määritetty käyttämään refresh tokeneja. Näitä ei tueta salasanapohjaisen kirjautumisen kanssa."
- "Valitsemasi kotipalvelin ei tue salasana- tai OIDC-kirjautumista. Ota yhteyttä palvelimesi ylläpitäjään tai valitse toinen kotipalvelin."
+ "Valitsemasi kotipalvelin ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palvelimesi ylläpitäjään tai valitse toinen kotipalvelin.""Anna tietosi""Matrix on avoin verkko turvallista, hajautettua viestintää varten.""Tervetuloa takaisin!""Kirjaudu sisään %1$s -palvelimelle"
+ "Avaa Element Classic"
+ "Avaa Element Classic laitteellasi"
+ "Mene kohtaan Asetukset > Tietoturva ja yksityisyys"
+ "Osiossa \"Salausavainten hallinta\", paina \"Salattujen viestien palautus\"."
+ "Noudata ohjeita"
+ "Palaa takaisin %1$s -sovellukseen"
+ "Ota avainten säilytys käyttöön ennen kuin jatkat %1$s -sovellukseen""Versio %1$s"
+ "Tarkistetaan tiliä…""Kirjaudu sisään manuaalisesti""Kirjaudu sisään %1$s -palvelimelle""Kirjaudu sisään QR-koodilla""Luo tili"
+ "Tervetuloa takaisin""Tervetuloa kaikkien aikojen nopeimpaan %1$s -sovellukseen. Ahdettu nopeudella ja yksinkertaisuudella.""Tervetuloa %1$s -sovellukseen. Ahdettu nopeudella ja yksinkertaisuudella.""Ole elementissäsi"
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 9846feec38..504517aad7 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -28,20 +28,29 @@
"Quelle est l’adresse de votre serveur ?""Choisissez votre serveur""Créer un compte"
- "Ce compte a été désactivé."
+ "Ce compte a été supprimé.""Nom d’utilisateur et/ou mot de passe incorrects""Il ne s’agit pas d’un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »""Ce serveur est configuré pour utiliser des tokens d’actualisation. Ils ne sont pas pris en charge lors de l’utilisation d’une connexion basée sur un mot de passe."
- "Le serveur d’accueil sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur d’accueil."
+ "Le serveur d’accueil sélectionné ne prend pas en charge le mot de passe ou la connexion OAuth. Contactez votre administrateur ou choisissez un autre serveur d’accueil.""Saisissez vos identifiants""Matrix est un réseau ouvert pour une communication sécurisée et décentralisée.""Content de vous revoir !""Connectez-vous à %1$s"
+ "Ouvrir Element Classic"
+ "Ouvrez Element Classic sur votre appareil"
+ "Aller à Paramètres > Sécurité et vie privée"
+ "Dans Gestion des clés cryptographiques, sélectionnez Récupération des messages chiffrés"
+ "Suivez les instructions pour activer votre stockage de clés"
+ "Revenez à %1$s"
+ "Activez le stockage de vos clés avant de continuer avec %1$s""Version %1$s"
+ "Vérification du compte""Se connecter manuellement""Connectez-vous à %1$s""Se connecter avec un code QR""Créer un compte"
+ "Bon retour parmi nous""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é.""Soyez dans votre Element"
diff --git a/features/login/impl/src/main/res/values-hr/translations.xml b/features/login/impl/src/main/res/values-hr/translations.xml
index ced196f87e..f578790380 100644
--- a/features/login/impl/src/main/res/values-hr/translations.xml
+++ b/features/login/impl/src/main/res/values-hr/translations.xml
@@ -32,16 +32,25 @@
"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."
+ "Odabrani matični poslužitelj ne podržava zaporku ili OAuth 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"
+ "Pokreni Element Classic"
+ "Otvorite Element Classic na svom uređaju"
+ "Idite na Postavke > Sigurnost i privatnost"
+ "šifrirane poruke"
+ "Slijedite upute za omogućavanje pohrane ključeva"
+ "Vrati se %1$s"
+ "Omogućite pohranu ključeva prije nego što nastavite na %1$s""Inačica %1$s"
+ "Provjera računa…""Prijavi se ručno""Prijavi se na poslužitelj %1$s""Prijavi se pomoću QR koda""Izradi račun"
+ "Dobro došli natrag!""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"
diff --git a/features/login/impl/src/main/res/values-hu/translations.xml b/features/login/impl/src/main/res/values-hu/translations.xml
index 06014b77b7..26534cc523 100644
--- a/features/login/impl/src/main/res/values-hu/translations.xml
+++ b/features/login/impl/src/main/res/values-hu/translations.xml
@@ -28,20 +28,29 @@
"Mi a kiszolgálója címe?""Válassza ki a kiszolgálóját""Fiók létrehozása"
- "Ez a fiók deaktiválva lett."
+ "Ez a fiók törölve lett.""Helytelen felhasználónév vagy jelszó""Ez nem érvényes felhasználóazonosító. A várt formátum: „@user:homeserver.org”""Ez a kiszolgáló frissítési tokenek használatára van beállítva. Ezek jelszó alapú bejelentkezés esetén nem támogatottak."
- "A kiválasztott Matrix-kiszolgáló nem támogatja a jelszavas vagy OIDC-alapú bejelentkezést. Lépjen kapcsolatba a kiszolgáló adminisztrátorával, vagy válasszon másik Matrix-kiszolgálót."
+ "A kiválasztott Matrix-kiszolgáló nem támogatja a jelszavas vagy OAuth-alapú bejelentkezést. Lépjen kapcsolatba a kiszolgáló adminisztrátorával, vagy válasszon másik Matrix-kiszolgálót.""Adja meg adatait""A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz.""Örülünk, hogy visszatért!""Bejelentkezés ide: %1$s"
+ "Nyissa meg az Element Classic alkalmazást"
+ "Nyissa meg az Element Classic alkalmazást az eszközén"
+ "Lépjen a Beállítások > Biztonság és adatvédelem menüponthoz"
+ "A Kriptográfiai kulcsok kezelése részben válassza a Titkosított üzenetek helyreállítása lehetőséget"
+ "Kövesse az utasításokat a kulcstároló engedélyezéséhez"
+ "Térjen vissza ide: %1$s"
+ "Engedélyezze a kulcstárolást a folytatás előtt ide: %1$s""Verzió: %1$s"
+ "Fiók ellenőrzése""Kézi bejelentkezés""Bejelentkezés ide: %1$s""Bejelentkezés QR-kóddal""Fiók létrehozása"
+ "Üdvözöljük újra!""Üdvözöljük a valaha volt leggyorsabb %1$sben. Felturbózva, a sebesség és az egyszerűség érdekében.""Üdvözli az %1$s. Felturbózva, a sebesség és az egyszerűség jegyében.""Legyen elemében"
diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml
index e05fd8746d..4b9f3ffdef 100644
--- a/features/login/impl/src/main/res/values-in/translations.xml
+++ b/features/login/impl/src/main/res/values-in/translations.xml
@@ -32,7 +32,7 @@
"Nama pengguna dan/atau kata sandi salah""Ini bukan pengenal pengguna yang valid. Format yang diharapkan: \'@pengguna:homeserver.org\'""Server ini diatur untuk menggunakan token penyegaran. Ini tidak didukung ketika menggunakan log masuk berbasis kata sandi."
- "Homeserver yang dipilih tidak mendukung log masuk kata sandi atau OIDC. Silakan hubungi admin Anda atau pilih homeserver yang lain."
+ "Homeserver yang dipilih tidak mendukung log masuk kata sandi atau OAuth. Silakan hubungi admin Anda atau pilih homeserver yang lain.""Masukkan detail Anda""Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi.""Selamat datang kembali!"
diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml
index 0de1cc0690..1f1b51cda8 100644
--- a/features/login/impl/src/main/res/values-it/translations.xml
+++ b/features/login/impl/src/main/res/values-it/translations.xml
@@ -28,20 +28,29 @@
"Qual è l\'indirizzo del tuo server?""Seleziona il tuo server""Crea account"
- "Questo account è stato disattivato."
+ "Questo account è stato eliminato.""Nome utente e/o password errati""Questo non è un identità utente valida. il formato atteso é: \'@user:homeserver.org\'""Questo server è configurato per usare i token di aggiornamento. Non sono supportati quando si usa l\'accesso basato su password."
- "L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."
+ "L\'homeserver selezionato non supporta la password o l\'accesso OAuth. Contatta il tuo amministratore o scegli un altro homeserver.""Inserisci i tuoi dati""Matrix è una rete aperta per comunicazioni sicure e decentralizzate.""Bentornato!""Accedi a %1$s"
+ "Apri Element Classic"
+ "Apri Element Classic sul tuo dispositivo"
+ "Vai su Impostazioni > Sicurezza & privacy"
+ "Nella gestione delle chiavi crittografiche, seleziona Recupero dei messaggi cifrati"
+ "Segui le istruzioni per abilitare l\'archiviazione delle chiavi"
+ "Torna a %1$s"
+ "Abilita l\'archivio delle chiavi prima di procedere con %1$s""Versione %1$s"
+ "Verifica dell\'account""Accedi manualmente""Accedi a %1$s""Accedi con codice QR""Crea account"
+ "Bentornato""Benvenuti nell\'%1$s più veloce di sempre. Potenziato per velocità e semplicità.""Benvenuto su %1$s. Potenziato in velocità e semplicità.""Sii nel tuo elemento"
diff --git a/features/login/impl/src/main/res/values-ja/translations.xml b/features/login/impl/src/main/res/values-ja/translations.xml
index e2c89ba26e..42698e27dc 100644
--- a/features/login/impl/src/main/res/values-ja/translations.xml
+++ b/features/login/impl/src/main/res/values-ja/translations.xml
@@ -1,18 +1,18 @@
- "アカウントの提供元を変更"
+ "アカウント提供元を変更""ホームサーバーのアドレス"
- "検索用のキーワードまたはドメインのアドレスを入力してください。"
+ "検索のキーワードまたはドメインのアドレスを入力してください。""会社やコミュニティ, 個人のサーバーなどを検索します。"
- "アカウントの提供元を検索"
- "メールアプリのように、あなたの会話はここに保管されています。"
+ "アカウント提供元を検索"
+ "メールアプリのように、あなたの会話はこのサーバー上に保管されます。""%s にサインインを試みています"
- "メールアプリのように、あなたの会話はここに保管されています。"
- "%s にアカウントの作成を試みています"
+ "メールアプリのように、あなたの会話はこのサーバー上に保管されます。"
+ "%s 上にアカウントを作成しようとしています""Matrix.org は Matrix.org Foundation が運営する、大規模で安全な分散型コミュニケーションを実現する無償のサーバーです。""その他"
- "自身のサーバーや仕事用のアカウントにサインインするには、アカウント提供元のサーバーを指定してください。"
- "アカウントの提供元を変更"
+ "自身のサーバーや仕事用のアカウントにサインインするには、アカウント提供元を変更してください。"
+ "アカウント提供元を変更""Google Play""%1$s では Element Pro を使用する必要があります。アプリストアよりダウンロードしてください。""Element Pro が必要です"
@@ -27,20 +27,29 @@
"サーバーのアドレスは何ですか?""サーバーを選択""アカウントを作成"
- "このアカウントは無効化されています。"
+ "アカウントは削除されました。""ユーザー名またはパスワードが違います""無効なユーザーIDです。正しい形式は \"@ユーザー:ホームサーバー\" です。""このサーバーはリフレッシュトークンを使用します。パスワードを使用したログインとは併用できません。"
- "指定したホームサーバはパスワードまたはOIDCによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。"
+ "指定したホームサーバはパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。""詳細を入力""Matrix は安全で分散型のオープンなネットワークです。""お待ちしておりました。""%1$s にサインイン"
+ "Element Classic を開く"
+ "Element Classic をこの端末で開く"
+ "「設定- セキュリティとプライバシー」に移動します"
+ "暗号鍵の管理から、暗号化されたメッセージの回復を選択します"
+ "指示に従って、鍵の保管庫を有効化してください"
+ "%1$s に戻ってください"
+ "%1$s に続行する前に、鍵の保管庫を有効化してください""バージョン %1$s"
+ "アカウントを確認中""手動で指定してサインイン""%1$s にサインイン""QRコードでサインイン""アカウントを作成"
+ "おかえりなさい""最速の %1$s にようこそ。機能性と利便性を極限まで追求しました。""機敏と利便を追求した %1$s へようこそ。""Be in your element"
@@ -71,8 +80,8 @@
"%1$s に非対応""読み取る""コンピュータで %1$s を開く"
- "アバターをタップしてください"
- "%1$s を選択してください"
+ "アバターをタップ"
+ "%1$s を選択""\"新しい端末を追加\"""この端末でQRコードを読み取る""アカウント提供元が対応する場合にのみ使用できます。"
@@ -89,11 +98,11 @@
"一方の端末を待機しています""アカウント提供元が、サインインを検証するために以下の文字列を要求することがあります。""検証コード"
- "アカウントの提供元を変更"
+ "アカウント提供元を変更""Element 開発者用の非公開のサーバーです。""Matrix は安全で分散型のオープンなネットワークです。"
- "メールアプリのように、あなたの会話はここに保管されています。"
+ "メールアプリのように、あなたの会話はこのサーバー上に保管されます。""%1$s にサインインを試みています""アカウント提供元を選択"
- "%1$s 上にアカウントの作成を試みています"
+ "%1$s 上にアカウントを作成しようとしています"
diff --git a/features/login/impl/src/main/res/values-ka/translations.xml b/features/login/impl/src/main/res/values-ka/translations.xml
index 54e277ec12..04db3fda13 100644
--- a/features/login/impl/src/main/res/values-ka/translations.xml
+++ b/features/login/impl/src/main/res/values-ka/translations.xml
@@ -23,7 +23,7 @@
"არასწორი მომხმარებლის სახელი და/ან პაროლი""მოცემული მომხმარებლის იდენტიფიკატორი არასწორია. დასაშვები ფორმატი: ‘@user:homeserver.org’""ეს სერვერი კონფიგურირებულია განახლების გასაღებების გამოსაყენებლად. პაროლზე დაფუძნებული შეცვლისას ისინი მხარდაჭერილი არაა."
- "მოცემული სახლის სერვერი მხარს არ უჭერს პაროლით ან OIDC-ით შესვლას. გთხოვთ, დაუკავშირდეთ თქვენს ადმინისტრატორს ან აარჩიეთ სხვა სახლის სერვერი."
+ "მოცემული სახლის სერვერი მხარს არ უჭერს პაროლით ან OAuth-ით შესვლას. გთხოვთ, დაუკავშირდეთ თქვენს ადმინისტრატორს ან აარჩიეთ სხვა სახლის სერვერი.""შეიყვანეთ თქვენი დეტალები""Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის.""კეთილი იყოს თქვენი მობრძანება!"
diff --git a/features/login/impl/src/main/res/values-ko/translations.xml b/features/login/impl/src/main/res/values-ko/translations.xml
index 69bf12cdb0..338ba628c9 100644
--- a/features/login/impl/src/main/res/values-ko/translations.xml
+++ b/features/login/impl/src/main/res/values-ko/translations.xml
@@ -32,16 +32,24 @@
"잘못된 아이디/비밀번호""이 사용자 ID는 유효하지 않습니다. 예상 형식: ‘@user:homeserver.org’""이 서버는 새로 고침 토큰을 사용하도록 구성되어 있습니다. 비밀번호 기반 로그인을 사용하는 경우 이 기능은 지원되지 않습니다."
- "선택한 홈 서버는 password 또는 OIDC 로그인을 지원하지 않습니다. 관리자에게 문의하거나 다른 홈 서버를 선택하세요."
+ "선택한 홈 서버는 password 또는 OAuth 로그인을 지원하지 않습니다. 관리자에게 문의하거나 다른 홈 서버를 선택하세요.""귀하의 세부 정보를 입력하십시오""Matrix 는 안전하고 분산된 커뮤니케이션을 위한 개방형 네트워크입니다.""다시 돌아온 걸 환영합니다!""%1$s 에 로그인합니다"
+ "Element Classic 열기"
+ "기기에서 Element Classic 앱을 열어 주세요"
+ "설정 > 보안 및 개인정보 보호로 이동하세요"
+ "암호화 키 관리에서 \'암호화된 메시지 복구\'를 선택하세요"
+ "안내에 따라 키 저장소를 활성화해 주세요"
+ "%1$s(으)로 돌아가기"
+ "%1$s(으)로 진행하기 전에 키 저장소를 활성화해 주세요.""버전 %1$s""수동으로 로그인""%1$s 에 로그인합니다""QR 코드로 로그인""계정 만들기"
+ "다시 오신 것을 환영합니다""%1$s 에 오신 것을 환영합니다. 속도와 단순성을 극대화한 가장 빠른 버전입니다.""%1$s 에 오신 것을 환영합니다. 속도와 단순성을 위해 최적화된 앱입니다.""당신의 엘리먼트에 있어"
diff --git a/features/login/impl/src/main/res/values-lt/translations.xml b/features/login/impl/src/main/res/values-lt/translations.xml
index f8f243f29d..dc1a2b7ba6 100644
--- a/features/login/impl/src/main/res/values-lt/translations.xml
+++ b/features/login/impl/src/main/res/values-lt/translations.xml
@@ -31,7 +31,7 @@
"Ši paskyra buvo išjungta.""Neteisingas vartotojo vardas ir (arba) slaptažodis""Tai nėra tinkamas vartotojo vardas. Reikalingas formatas: \'@vartotojas:serveris.org\'"
- "Pasirinktas serveris nepalaiko slaptažodžio ar OIDC prisijungimo. Susisiekite su serverio administracija arba pasirinkite kitą serverį."
+ "Pasirinktas serveris nepalaiko slaptažodžio ar OAuth prisijungimo. Susisiekite su serverio administracija arba pasirinkite kitą serverį.""Įveskite savo duomenis""Matrix yra atviras tinklas, skirtas saugiam, decentralizuotam bendravimui.""Sveiki sugrįžę!"
@@ -41,6 +41,7 @@
"Prisijungti prie %1$s""Prisijungti su QR kodu""Kurti paskyrą"
+ "Sveiki sugrįžę""Sveiki atvykę į sparčiausią „%1$s“ kada nors. Pagerintas spartai ir paprastumui.""Sveiki atvykę į „%1$s“. Pagerintas spartai ir paprastumui.""Būkite savo stichijoje"
diff --git a/features/login/impl/src/main/res/values-nb/translations.xml b/features/login/impl/src/main/res/values-nb/translations.xml
index 3233329f86..6645691b84 100644
--- a/features/login/impl/src/main/res/values-nb/translations.xml
+++ b/features/login/impl/src/main/res/values-nb/translations.xml
@@ -32,7 +32,7 @@
"Feil brukernavn og/eller passord""Dette er ikke en gyldig brukeridentifikator. Forventet format: \'@bruker:homeserver.org\'""Denne serveren er konfigurert til å bruke oppdateringstokener. Disse støttes ikke når du bruker passordbasert pålogging."
- "Den valgte hjemmeserveren støtter ikke passord eller OIDC-pålogging. Ta kontakt med administratoren din eller velg en annen hjemmeserver."
+ "Den valgte hjemmeserveren støtter ikke passord eller OAuth-pålogging. Ta kontakt med administratoren din eller velg en annen hjemmeserver.""Skriv inn opplysningene dine""Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon.""Velkommen tilbake!"
@@ -42,6 +42,7 @@
"Logg inn på %1$s""Logg inn med QR-kode""Opprett konto"
+ "Velkommen tilbake""Velkommen til den raskeste %1$s noensinne. Superladet for hastighet og enkelhet.""Velkommen til %1$s. Supercharged, for hastighet og enkelhet.""Vær i ditt rette element"
diff --git a/features/login/impl/src/main/res/values-nl/translations.xml b/features/login/impl/src/main/res/values-nl/translations.xml
index 8a7f695156..e5d90e2234 100644
--- a/features/login/impl/src/main/res/values-nl/translations.xml
+++ b/features/login/impl/src/main/res/values-nl/translations.xml
@@ -24,7 +24,7 @@
"Onjuiste gebruikersnaam en/of wachtwoord""Dit is geen geldige gebruikers-ID. Verwacht formaat: \'@user:homeserver.org\'""Deze server is geconfigureerd om verversingstokens te gebruiken. Deze worden niet ondersteund bij inloggen met een wachtwoord."
- "De geselecteerde homeserver ondersteunt geen wachtwoord of OIDC aanmelding. Neem contact op met je beheerder of kies een andere homeserver."
+ "De geselecteerde homeserver ondersteunt geen wachtwoord of OAuth aanmelding. Neem contact op met je beheerder of kies een andere homeserver.""Vul je gegevens in""Matrix is een open netwerk voor veilige, gedecentraliseerde communicatie.""Welkom terug!"
diff --git a/features/login/impl/src/main/res/values-pl/translations.xml b/features/login/impl/src/main/res/values-pl/translations.xml
index 0c7810abe7..ca0d035fe0 100644
--- a/features/login/impl/src/main/res/values-pl/translations.xml
+++ b/features/login/impl/src/main/res/values-pl/translations.xml
@@ -28,20 +28,29 @@
"Jaki jest adres Twojego serwera?""Wybierz swój serwer""Utwórz konto"
- "To konto zostało dezaktywowane."
+ "To konto zostało usunięte.""Nieprawidłowa nazwa użytkownika i/lub hasło""To nie jest prawidłowy identyfikator użytkownika. Oczekiwany format: \'@user:homeserver.org\'""Ten serwer został skonfigurowany do korzystania z tokenów odświeżania. Nie są one obsługiwane, gdy korzystasz z hasła."
- "Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OIDC. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy."
+ "Wybrany serwer domowy nie obsługuje uwierzytelniania hasłem, ani OAuth. Skontaktuj się z jego administratorem lub wybierz inny serwer domowy.""Wprowadź swoje dane""Matrix to otwarta sieć do bezpiecznej i zdecentralizowanej komunikacji.""Witaj ponownie!""Zaloguj się do %1$s"
+ "Otwórz Element Classic"
+ "Otwórz Element Classic na swoim urządzeniu"
+ "Przejdź do Ustawienia > Bezpieczeństwo i prywatność"
+ "W Zarządzaniu kluczami kryptograficznymi wybierz przywracanie wiadomości szyfrowanych"
+ "Aby włączyć magazyn kluczy, postępuj zgodnie z instrukcjami"
+ "Wróć do %1$s"
+ "Włącz magazyn kluczy zanim przejdziesz do %1$s""Wersja %1$s"
+ "Sprawdzanie konta""Zaloguj się ręcznie""Zaloguj się do %1$s""Zaloguj się za pomocą kodu QR""Utwórz konto"
+ "Witamy ponownie""Witamy w %1$s. Szybszy i prostszy niż kiedykolwiek.""Witamy w %1$s. Doładowany, dla szybkości i prostoty.""Be in your element"
@@ -60,6 +69,8 @@
"Prośba o logowanie została anulowana""Logowanie zostało odrzucone na drugim urządzeniu.""Logowanie odrzucone"
+ "Nie musisz już robić nic więcej."
+ "Twoje drugie urządzenie jest już zalogowane""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.
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 afca14d201..ad2ad2b5fb 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
@@ -32,7 +32,7 @@
"Nome de usuário e/ou senha incorretos""Esse não é um identificador de usuário válido. Formato esperado: \'@usuário:servidor.org\'""Este servidor está configurado para usar tokens recarregados. Não há suporte a eles ao entrar por uma senha."
- "O servidor selecionado não suporta a entrada por senha ou OIDC. Entre em contato com o administrador ou escolha outro servidor."
+ "O servidor selecionado não suporta a entrada por senha ou OAuth. Entre em contato com o administrador ou escolha outro servidor.""Digite seus dados""A Matrix é uma rede aberta para comunicação segura e descentralizada.""Boas-vindas novamente!"
diff --git a/features/login/impl/src/main/res/values-pt/translations.xml b/features/login/impl/src/main/res/values-pt/translations.xml
index c2aa0d5ed6..ff3cae843d 100644
--- a/features/login/impl/src/main/res/values-pt/translations.xml
+++ b/features/login/impl/src/main/res/values-pt/translations.xml
@@ -28,11 +28,11 @@
"Qual é o endereço do teu servidor?""Seleciona o teu servidor""Criar conta"
- "Esta conta foi desativada."
+ "Esta conta foi eliminada.""Nome de utilizador ou senha incorretos""Identificador de utilizador inválido. Formato esperado: ‘@utilizador:servidor.org’""Este servidor está configurado para utilizar \"tokens\" de atualização. Estes não são suportados quando utilizas o início de sessão por senha."
- "O servidor selecionado não suporta início de sessão por senha nem por OIDC. Por favor, contacta o teu administrador ou escolhe outro servidor."
+ "O servidor selecionado não suporta início de sessão por senha nem por OAuth. Por favor, contacta o teu administrador ou escolhe outro servidor.""Insere o teus detalhes""A Matrix é uma rede aberta de comunicação descentralizada e segura.""Bem-vindo(a) de volta!"
diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml
index 6dddc4d0bf..b44242abf8 100644
--- a/features/login/impl/src/main/res/values-ro/translations.xml
+++ b/features/login/impl/src/main/res/values-ro/translations.xml
@@ -28,20 +28,29 @@
"Care este adresa serverului dumneavoastră?""Selectați serverul dumneavoastra""Creați un cont"
- "Acest cont a fost dezactivat."
+ "Acest cont a fost șters.""Utilizator și/sau parolă incorecte""Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”""Acest server este configurat pentru a utiliza token-uri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați autentificare bazată pe parolă."
- "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver."
+ "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OAuth. Te rugăm să contactezi administratorul sau să alegi un alt homeserver.""Introduceți detaliile""Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată.""Bine ați revenit!""Conectați-vă la %1$s"
+ "Deschideți Element Clasic"
+ "Deschideți Element Classic pe dispozitivul dumneavoastră"
+ "Accesați Setări > Securitate și confidențialitate"
+ "În Gestionarea cheilor criptografice, selectați Recuperarea mesajelor criptate"
+ "Urmați instrucțiunile pentru a activa stocarea cheilor"
+ "Reveniți la %1$s"
+ "Activați stocarea cheilor înainte de a continua către %1$s""Versiunea %1$s"
+ "Se verifică contul…""Conectați-vă manual""Conectați-vă la %1$s""Conectați-vă cu un cod QR""Creați un cont"
+ "Bine ați revenit""Bine ați venit la cel mai rapid %1$s din toate timpurile. Supraalimentat pentru viteză și simplitate.""Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate.""Fii în Elementul tău"
@@ -60,6 +69,8 @@
"Cererea de autentificare a fost anulată""Autentificarea a fost refuzată pe celălalt dispozitiv.""Autentificarea a fost refuzată"
+ "Nu trebuie să faceți nimic altceva."
+ "Celălalt dispozitiv este deja conectat""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.
diff --git a/features/login/impl/src/main/res/values-ru/translations.xml b/features/login/impl/src/main/res/values-ru/translations.xml
index b967224f2e..564fc87cac 100644
--- a/features/login/impl/src/main/res/values-ru/translations.xml
+++ b/features/login/impl/src/main/res/values-ru/translations.xml
@@ -28,20 +28,29 @@
"Какой адрес у вашего сервера?""Выберите свой сервер""Создать аккаунт"
- "Данная учётная запись была отключена."
+ "Эта учетная запись была удалена.""Неверное имя пользователя и/или пароль""Это некорректный идентификатор пользователя. Правильный формат: @user:homeserver.org""Этот сервер настроен на использование токенов обновления. Они не поддерживаются при использовании входа на основе пароля."
- "Выбранный сервер не поддерживает вход по паролю и OIDC. Пожалуйста, свяжитесь с администратором или выберите другой сервер."
+ "Выбранный сервер не поддерживает вход по паролю и OAuth. Пожалуйста, свяжитесь с администратором или выберите другой сервер.""Введите свои данные""Matrix — это открытая сеть для безопасной децентрализованной связи.""Рады видеть вас снова!""Войти в %1$s"
+ "Открыть Element Classic"
+ "Откройте Element Classic на своем устройстве."
+ "Перейдите в Настройки > Безопасность и конфиденциальность"
+ "В разделе «Управление криптографическими ключами» выбери «Восстановление зашифрованных сообщений»"
+ "Следуйте инструкциям, чтобы активировать хранилище ключей"
+ "Вернитесь к %1$s"
+ "Перед продолжением активируйте хранилище ключей %1$s""Версия %1$s"
+ "Проверка аккаунта""Войти""Войти в %1$s""Войти с QR-кодом""Создать аккаунт"
+ "С возвращением""Добро пожаловать в быстрый и простой %1$s.""Добро пожаловать в быстрый и простой %1$s.""Элементарно."
diff --git a/features/login/impl/src/main/res/values-sk/translations.xml b/features/login/impl/src/main/res/values-sk/translations.xml
index 9bd494dd56..b2c3077de2 100644
--- a/features/login/impl/src/main/res/values-sk/translations.xml
+++ b/features/login/impl/src/main/res/values-sk/translations.xml
@@ -32,7 +32,7 @@
"Nesprávne používateľské meno a/alebo heslo""Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'""Tento server je nakonfigurovaný tak, aby používal obnovovacie tokeny. Pri prihlasovaní na základe hesla nie sú podporované."
- "Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OIDC. Obráťte sa na správcu alebo vyberte iný domovský server."
+ "Vybraný domovský server nepodporuje prihlásenie pomocou hesla alebo OAuth. Obráťte sa na správcu alebo vyberte iný domovský server.""Zadajte svoje údaje""Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu.""Vitajte späť!"
diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml
index 33fb76b5bd..396f2025b7 100644
--- a/features/login/impl/src/main/res/values-sv/translations.xml
+++ b/features/login/impl/src/main/res/values-sv/translations.xml
@@ -32,7 +32,7 @@
"Felaktigt användarnamn och/eller lösenord""Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'""Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."
- "Den valda hemservern stöder inte lösenord eller OIDC-inloggning. Kontakta administratören eller välj en annan hemserver."
+ "Den valda hemservern stöder inte lösenord eller OAuth-inloggning. Kontakta administratören eller välj en annan hemserver.""Ange dina uppgifter""Matrix är ett öppet nätverk för säker, decentraliserad kommunikation.""Välkommen tillbaka!"
diff --git a/features/login/impl/src/main/res/values-tr/translations.xml b/features/login/impl/src/main/res/values-tr/translations.xml
index 1574fca3a8..05bb9bab15 100644
--- a/features/login/impl/src/main/res/values-tr/translations.xml
+++ b/features/login/impl/src/main/res/values-tr/translations.xml
@@ -28,7 +28,7 @@
"Yanlış kullanıcı adı ve/veya şifre""Bu geçerli bir kullanıcı tanımlayıcısı değil. Kullanılması gereken biçim: \'@user:homeserver.org\'""Bu sunucu, yenileme belirteçlerini kullanacak şekilde yapılandırılmıştır. Parola tabanlı oturum açma kullanılırken bunlar desteklenmez."
- "Seçilen ana sunucu parola veya OIDC oturum açmayı desteklemiyor. Lütfen yöneticinizle iletişime geçin veya başka bir ana sunucu seçin."
+ "Seçilen ana sunucu parola veya OAuth oturum açmayı desteklemiyor. Lütfen yöneticinizle iletişime geçin veya başka bir ana sunucu seçin.""Bilgilerinizi girin""Matrix, güvenli, merkezi olmayan iletişim için açık bir ağdır.""Tekrar hoş geldiniz!"
diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml
index b93a66fed2..17632cc4fc 100644
--- a/features/login/impl/src/main/res/values-uk/translations.xml
+++ b/features/login/impl/src/main/res/values-uk/translations.xml
@@ -28,20 +28,29 @@
"Яка адреса вашого сервера?""Виберіть свій сервер""Створити обліковий запис"
- "Цей обліковий запис було деактивовано."
+ "Цей обліковий запис було видалено.""Неправильне ім\'я користувача та/або пароль""Це недійсний ідентифікатор користувача. Очікуваний формат: \'@user:homeserver.org\'""Цей сервер налаштований на використання оновлюваних токенів. Вони не підтримуються, якщо використовується вхід за допомогою основі пароля."
- "Обраний домашній сервер не підтримує вхід за допомогою пароля або OIDC. Зверніться до адміністратора або виберіть інший домашній сервер."
+ "Обраний домашній сервер не підтримує вхід за допомогою пароля або OAuth. Зверніться до адміністратора або виберіть інший домашній сервер.""Введіть свої дані""Matrix — це відкрита мережа для безпечної, децентралізованої комунікації.""З поверненням!""Увійти в %1$s"
+ "Відкрити Element Classic"
+ "Відкрийте Element Classic на своєму пристрої"
+ "Перейдіть до «Налаштування» > «Безпека та конфіденційність»"
+ "У розділі «Управління криптографічними ключами» виберіть «Відновлення зашифрованих повідомлень»"
+ "Дотримуйтесь інструкцій, щоб увімкнути сховище ключів"
+ "Повернутися до %1$s"
+ "Увімкніть сховище ключів, перш ніж переходити до %1$s""Версія %1$s"
+ "Перевірка облікового запису""Увійти вручну""Увійти в %1$s""Увійти за допомогою QR-коду""Створити обліковий запис"
+ "З поверненням!""Ласкаво просимо до найшвидшого %1$s. Заряджений для швидкості та простоти.""Ласкаво просимо до %1$s. Заряджений, для швидкості та простоти.""Будьте у своєму element"
diff --git a/features/login/impl/src/main/res/values-ur/translations.xml b/features/login/impl/src/main/res/values-ur/translations.xml
index 334d5b6ea6..6762ab16af 100644
--- a/features/login/impl/src/main/res/values-ur/translations.xml
+++ b/features/login/impl/src/main/res/values-ur/translations.xml
@@ -24,7 +24,7 @@
"غلط صارف نام اور/یا لفظ عبور""یہ صالح صارف شناسه نہیں ہے۔ متوقع شکل: @صارف:منزلی خادم""یہ خادم تازگی کی رموزِ ممیز استعمال کرنے کے لئے تشکیل دیا گیا ہے۔ لفظ عبور پر مبنی دخول استعمال کرتے ہوئے ان کی حمایت نہیں کی جاتی۔"
- "منتخب منزلی خادم کلمۂ عبوری یا OIDC دخول کا تعاون نہیں کرتا۔ برائے مہربانی اپنے منتظم سے رابطہ کریں یا کوئی اور منزلی خادم چنیں۔"
+ "منتخب منزلی خادم کلمۂ عبوری یا OAuth دخول کا تعاون نہیں کرتا۔ برائے مہربانی اپنے منتظم سے رابطہ کریں یا کوئی اور منزلی خادم چنیں۔""اپنی تفصیلات درج کریں""میٹرکس محفوظ، غیر مرکزی مواصلت کے لئے ایک کھلا شبکہ ہے۔""واپس خوش آمدید!"
diff --git a/features/login/impl/src/main/res/values-uz/translations.xml b/features/login/impl/src/main/res/values-uz/translations.xml
index 54a1cf11cc..546397935b 100644
--- a/features/login/impl/src/main/res/values-uz/translations.xml
+++ b/features/login/impl/src/main/res/values-uz/translations.xml
@@ -31,16 +31,25 @@
"Notog\'ri foydalanuvchi nomi va/yoki parol""Bu haqiqiy foydalanuvchi identifikatori emas. Kutilayotgan format: \'@user:homeserver.org\'""Ushbu server yangilash tokenlaridan foydalanishga moslashtirilgan. Parolga asoslangan tizimga kirishda bunday tokenlar qoʻllab-quvvatlanmaydi."
- "Tanlangan uy serveri parol yoki OIDC loginni qo\'lab-quvvatlamaydi. Iltimos, administratoringizga murojaat qiling yoki boshqa uy serverini tanlang."
+ "Tanlangan uy serveri parol yoki OAuth loginni qo\'lab-quvvatlamaydi. Iltimos, administratoringizga murojaat qiling yoki boshqa uy serverini tanlang.""Tafsilotlaringizni kiriting""Matrix xavfsiz, markazlashmagan aloqa uchun ochiq tarmoqdir.""Qaytib kelganingizdan xursandmiz!""Kirish%1$s"
+ "Element Classic ilovasini ochish"
+ "Element Classic ilovasini qurilmada oching"
+ "Sozlamalar > Xavfsizlik va maxfiylik bo‘limiga kiring"
+ "Kriptografiya kalitlarini boshqarishda Shifrlangan xabarlarni tiklash bandini tanlang"
+ "Kalit xotirasini yoqish uchun ko‘rsatmalarga amal qiling"
+ "%1$sga qaytish"
+ "%1$s xizmatiga o‘tishdan oldin kalit xotirasini yoqing""%1$s versiya"
+ "Joriy hisob""Qo\'lda tizimga kiring""Kirish%1$s""QR kod bilan tizimga kiring""Hisob yaratish"
+ "Xush kelibsiz.""Eng tezkor %1$sga xush kelibsiz. Tezlik va oddiylik uchun super zaryadlangan.""%1$sga Xush kelibsiz. Tezlik va oddiylik uchun o\'ta zaryadlangan.""Elementingizda bo\'ling"
@@ -59,6 +68,8 @@
"Tizimga kirish soʻrovi bekor qilindi""Boshqa qurilmadan hisobga kirish bekor qilindi.""Tizimga kirish rad etildi"
+ "Boshqa hech narsa qilishingiz shart emas."
+ "Boshqa qurilmangiz allaqachon tizimga kirgan""Kirish muddati tugagan. Iltimos, qayta urinib koʻring.""Kirish oʻz vaqtida tugallanmagan""Boshqa qurilmangiz %s hisobiga QR kod orqali kirishni qoʻllab-quvvatlamaydi.
diff --git a/features/login/impl/src/main/res/values-vi/translations.xml b/features/login/impl/src/main/res/values-vi/translations.xml
index b22ba5de7c..66089a4708 100644
--- a/features/login/impl/src/main/res/values-vi/translations.xml
+++ b/features/login/impl/src/main/res/values-vi/translations.xml
@@ -13,9 +13,16 @@
"Khác""Sử dụng nhà cung cấp tài khoản khác, ví dụ như máy chủ riêng của bạn hoặc tài khoản công việc.""Thay đổi nhà cung cấp tài khoản"
+ "Google Play"
+ "Ứng dụng Element Pro là bắt buộc trên %1$s. Vui lòng tải xuống từ cửa hàng."
+ "Element Pro là bắt buộc""Chúng tớ không thể kết nối với homeserver này. Vui lòng kiểm tra xem cậu đã nhập URL homeserver chính xác chưa. Nếu URL chính xác, hãy liên hệ với quản trị viên homeserver để được hỗ trợ thêm.""Máy chủ không khả dụng do sự cố trong tệp .well-known:
%1$s"
+ "Nhà cung cấp tài khoản đã chọn không hỗ trợ đồng bộ sliding. Cần nâng cấp máy chủ để sử dụng %1$s ."
+ "%1$s không được phép kết nối với %2$s ."
+ "Ứng dụng này đã được cấu hình để cho phép: %1$s ."
+ "Không cho phép nhà cung cấp tài khoản %1$s.""URL homeserver""Địa chỉ máy chủ của bạn là gì?""Chọn máy chủ của bạn"
@@ -24,7 +31,7 @@
"Tên người dùng và/hoặc mật khẩu không chính xác""Đây không phải là mã nhận dạng người dùng hợp lệ. Định dạng mong đợi: ‘@user:homeserver.org’""Máy chủ này được cấu hình sử dụng refresh token. Điều này không được hỗ trợ khi đăng nhập bằng mật khẩu."
- "Homeserver đã chọn không hỗ trợ đăng nhập bằng mật khẩu hoặc OIDC. Vui lòng liên hệ với quản trị viên của cậu hoặc chọn một homeserver khác."
+ "Homeserver đã chọn không hỗ trợ đăng nhập bằng mật khẩu hoặc OAuth. Vui lòng liên hệ với quản trị viên của cậu hoặc chọn một homeserver khác.""Nhập thông tin chi tiết của bạn.""Matrix là một mạng mở cho việc liên lạc an toàn và phi tập trung.""Chào mừng bạn quay trở lại!"
diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml
index 1b0b94d7a6..c4d16f5928 100644
--- a/features/login/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml
@@ -32,16 +32,25 @@
"不正確的使用者名稱或密碼""此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’""此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"
- "選定的家伺服器不支援密碼或 OIDC 登入。請聯絡您的管理員或選擇其他家伺服器。"
+ "選定的家伺服器不支援密碼或 OAuth 登入。請聯絡您的管理員或選擇其他家伺服器。""輸入您的詳細資料""Matrix 是一個開放網路,為了安全且去中心化的通訊而生。""歡迎回來!""登入 %1$s"
+ "開啟 Element Classic"
+ "在您的裝置上開啟 Element Classic"
+ "前往「設定」→「安全性與隱私權」"
+ "在密碼學金鑰管理中,選取加密訊息還原"
+ "按照說明啟用您的金鑰儲存空間"
+ "回到 %1$s"
+ "請先啟用您的金鑰儲存空間,然後再繼續 %1$s""版本 %1$s"
+ "檢查帳號""手動登入""登入 %1$s""使用 QR code 登入""建立帳號"
+ "歡迎回來""歡迎使用有史以來最快的 %1$s。速度超快,操作簡便。""歡迎使用 %1$s。速度超快且簡單。""Be in your element"
@@ -60,6 +69,8 @@
"已取消登入請求""其他裝置拒絕登入。""已拒絕登入"
+ "您不需要進行其他操作。"
+ "您的其他裝置已登入""登入已過期。請再試一次。""未及時完成登入""您的其他裝置不支援使用 QR cpde 登入 %s。
diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml
index fd8105b71e..16afe7d5bd 100644
--- a/features/login/impl/src/main/res/values-zh/translations.xml
+++ b/features/login/impl/src/main/res/values-zh/translations.xml
@@ -1,100 +1,109 @@
- "更改账户提供方"
- "服务器地址"
+ "更改账户提供者"
+ "主服务器地址""输入搜索词或域名地址。""搜索公司、社区或私人服务器。"
- "寻找账户提供方"
- "这是您的对话将存在的地方,就像您使用电子邮件提供方来保存电子邮件一样。"
- "您即将登录 %s"
- "这是您的对话将存在的地方,就像您使用电子邮件提供方来保存电子邮件一样。"
- "您即将在 %s 上创建一个帐户"
+ "查找账户提供者"
+ "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"
+ "你即将登录到 %s"
+ "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"
+ "你即将在 %s 上创建账户""Matrix.org 由 Matrix.org 基金会运营,是用于安全、去中心化的通信的公共 Matrix 网络上的大型免费服务器。"
- "其他"
- "使用其他账户提供商,例如您自己的私人服务器或工作账户。"
- "更改账户提供方"
+ "其它"
+ "使用其它账户提供者,例如你自己的私有服务器或工作账户。"
+ "更改账户提供者""Google Play"
- "%1$s 需要 Element Pro 应用。请从应用商店下载。"
- "需要 Element Pro 版"
- "我们无法访问此服务器。请检查您输入的服务器网址是否正确。如果 URL 正确,请联系您的服务器管理员寻求进一步帮助。"
- "由于 .well-known 文件中存在问题,服务器不可用:
+ "%1$s 要求 Element Pro。请从应用商店下载。"
+ "需要 Element Pro"
+ "我们无法访问此服务器。请检查输入的服务器 URL 是否正确。如果 URL 正确,请联系服务器管理员寻求进一步帮助。"
+ "由于 .well-known 文件存在问题,服务器不可用:
%1$s"
- "所选账户提供商不支持跨屏同步。需要升级服务器才能使用%1$s。"
- "%1$s不允许连接到%2$s。"
- "本应用已配置为允许访问:%1$s 。"
- "账户提供商%1$s 不被允许。"
- "服务器网址"
+ "所选账户提供者不支持滑动同步。需要升级服务器才能使用 %1$s。"
+ "%1$s 不允许连接到 %2$s。"
+ "此 app 已配置为允许访问:%1$s。"
+ "账户提供者 %1$s 不被允许。"
+ "主服务器 URL""输入域名地址。"
- "您的服务器地址是什么?"
+ "你的服务器地址是什么?""选择服务器""创建账户"
- "该账户已被停用。"
- "错误的用户名和/或密码"
- "这不是合法的用户 ID。期望格式:‘@user:homeserver.org’。"
+ "此账户已被删除。"
+ "用户名与(或)密码不正确"
+ "这不是合法的用户 ID。预期格式:“@user:homeserver.org”。""此服务器使用刷新令牌。使用密码登录时不支持这些功能。"
- "该服务器不支持密码登录和 OIDC 第三方账户登录。请联系服务器管理员,或选择别的服务器。"
- "输入您的详细信息"
+ "该服务器不支持密码登录与 OAuth 登录。请联系服务器管理员或选择另一服务器。"
+ "输入详细信息""Matrix 是一个用于安全、去中心化通信的开放网络。""欢迎回来!""登录到 %1$s"
+ "打开 Element Classic"
+ "在你的设备上打开 Element Classic"
+ "前往“设置” > “安全与隐私”"
+ "在加密密钥管理中选择“恢复加密消息”。"
+ "按指示启用密钥存储"
+ "返回到 %1$s"
+ "请先启用密钥存储再继续处理 %1$s""版本%1$s"
+ "正在检查账户""手动登录""登录到 %1$s""使用二维码登录""创建账户"
- "欢迎使用 %1$s,快而简约的消息应用。"
+ "欢迎回来"
+ "欢迎使用迄今最快的 %1$s,速度与简洁的极致。""欢迎使用 %1$s,速度与简洁的极致。"
- "融入您的 Element"
+ "融入 Element""建立安全连接"
- "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。"
+ "无法与新设备建立安全连接。你的现有设备仍然安全,无需担心。""现在怎么办?""如果这是网络问题,请尝试使用二维码再次登录""如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi""如果不起作用,请手动登录""连接不安全"
- "您会被要求输入此设备上显示的两位数。"
- "在您的其他设备上输入下面的数字"
- "在其他设备登录后重试,或使用另一个已登录的设备。"
- "其他设备未登录"
+ "你将被要求输入此设备上显示的两位数字。"
+ "在你的其它设备上输入以下数字"
+ "在其它设备登录后重试,或使用另一个已登录的设备。"
+ "尚未登录的其它设备""登录被另一台设备取消""登录请求已取消"
- "其它设备未接受请求"
+ "另一设备上的登录请求已被拒绝。""登录被拒绝"
- "您无需额外操作。"
- "您已在另一台设备登录。"
+ "无需额外操作。"
+ "你已在另一设备上登录。""登录已过期. 请重试.""登录未及时完成""另一个设备不支持使用二维码登录 %s.
尝试手动或使用另一个设备扫描二维码."
- "不支持二维码"
- "账户提供方不支持 %1$s."
+ "二维码不受支持"
+ "账户提供者不支持 %1$s.""不支持 %1$s.""准备进行扫描""在桌面设备上打开 %1$s""点击你的头像""选择 %1$s"
- "「连接新设备」"
+ "“关联新设备”""使用此设备扫描二维码"
- "仅在您的账户提供方支持时才可用。"
+ "仅在账户提供者支持时可用。""在另一台设备上打开 %1$s 以获取二维码"
- "使用其他设备上显示的二维码。"
- "再试一次"
+ "使用其它设备上显示的二维码。"
+ "重试""二维码错误""转到摄像头设置"
- "您需要授予 %1$s 使用设备摄像头的权限才能继续。"
- "允许摄像头权限以扫描 QR 码"
+ "你需要授予 %1$s 使用设备摄像头的权限才能继续。"
+ "允许访问摄像头以扫描二维码""扫描二维码""重新开始""发生了意外错误。请再试一次。"
- "等着您的其他设备"
- "您的账户提供方可能会要求您提供以下代码来验证登录。"
- "您的验证码"
- "更改账户提供方"
+ "正在等待其它设备"
+ "你的账户提供者可能会要求你提供以下代码以验证登录。"
+ "你的验证码"
+ "更改账户提供者""专为 Element 员工提供的私人服务器。""Matrix 是一个用于安全、去中心化通信的开放网络。"
- "这是您的对话将存在的地方,就像您使用电子邮件提供方来保存电子邮件一样。"
+ "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。""即将登录 %1$s"
- "选择账户提供商"
+ "选择账户提供者""即将在 %1$s 上创建一个账户"
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index b4dee32721..10fb6ef04a 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -28,11 +28,11 @@
"What is the address of your server?""Select your server""Create account"
- "This account has been deactivated."
+ "This account has been deleted.""Incorrect username and/or password""This is not a valid user identifier. Expected format: ‘@user:homeserver.org’""This server is configured to use refresh tokens. These aren\'t supported when using password based login."
- "The selected homeserver doesn\'t support password or OIDC login. Please contact your admin or choose another homeserver."
+ "The selected homeserver doesn\'t support password or OAuth login. Please contact your admin or choose another homeserver.""Enter your details""Matrix is an open network for secure, decentralised communication.""Welcome back!"
@@ -40,11 +40,12 @@
"Open Element Classic""Open Element Classic on your device""Go to Settings > Security & Privacy"
- "In Cryptography keys management, select Encrypted message recovery"
+ "In Cryptography keys management, select Encrypted messages recovery""Follow the instructions to enable your key storage""Come back to %1$s""Enable your key storage before proceeding to %1$s""Version %1$s"
+ "Checking account""Sign in manually""Sign in to %1$s""Sign in with QR code"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
index 86a629270f..a05194d008 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt
@@ -17,7 +17,7 @@ import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.features.preferences.test.FakePreferencesEntryPoint
-import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
+import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
@@ -39,7 +39,7 @@ class DefaultLoginEntryPointTest {
buildContext = buildContext,
plugins = plugins,
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
- oidcActionFlow = FakeOidcActionFlow(),
+ oAuthActionFlow = FakeOAuthActionFlow(),
appCoroutineScope = backgroundScope,
elementClassicConnection = FakeElementClassicConnection(),
preferencesEntryPoint = FakePreferencesEntryPoint(),
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
index 1fb5d37627..274b58ee49 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt
@@ -50,7 +50,7 @@ class ChangeServerPresenterTest {
fun `present - change server ok`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
createPresenter(
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt
index 5da3c97f3c..8ea1b2e3d3 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt
@@ -15,9 +15,6 @@ import androidx.core.graphics.createBitmap
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.service.ServiceBinder
-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.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -112,21 +109,6 @@ class DefaultElementClassicConnectionTest {
}
}
- @Test
- fun `requestSession when the feature is disabled emits an error`() = runTest {
- val connection = createDefaultElementClassicConnection(
- matrixAuthenticationService = FakeMatrixAuthenticationService(
- setElementClassicSessionResult = {},
- ),
- isFeatureEnabled = false,
- )
- connection.stateFlow.test {
- assertThat(awaitItem()).isEqualTo(ElementClassicConnectionState.Idle)
- connection.requestSession()
- assertThat(awaitItem()).isInstanceOf(ElementClassicConnectionState.Error::class.java)
- }
- }
-
@Test
fun `when an error is received, an error is emitted`() = runTest {
val connection = createDefaultElementClassicConnection(
@@ -514,17 +496,10 @@ class DefaultElementClassicConnectionTest {
homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(
checkResult = { Result.success(true) }
),
- isFeatureEnabled: Boolean = true,
- featureFlagService: FeatureFlagService = FakeFeatureFlagService(
- initialState = mapOf(
- FeatureFlags.SignInWithClassic.key to isFeatureEnabled,
- )
- ),
) = DefaultElementClassicConnection(
serviceBinder = serviceBinder,
coroutineScope = coroutineScope,
matrixAuthenticationService = matrixAuthenticationService,
homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker,
- featureFlagService = featureFlagService,
)
}
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt
index 9d2628005c..112d8d7108 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt
@@ -79,7 +79,7 @@ class QrCodeLoginFlowNodeTest {
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.ConnectionInsecure)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected))
- qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OidcMetadataInvalid)
+ qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OAuthMetadataInvalid)
assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError))
qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Unknown)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt
index f7ff5d384d..61ec7cc698 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt
@@ -6,17 +6,20 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -25,36 +28,31 @@ 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 org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class ChooseAccountProviderViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back invokes the expected callback`() {
+ fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnce {
- rule.setChooseAccountProviderView(
+ setChooseAccountProviderView(
state = aChooseAccountProviderState(
eventSink = eventSink,
),
onBackClick = it,
)
- rule.pressBack()
+ pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
- fun `selecting an account provider emits the the expected event`() {
+ fun `selecting an account provider emits the the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
- rule.setChooseAccountProviderView(
+ setChooseAccountProviderView(
state = aChooseAccountProviderState(
accountProviders = listOf(
ChooseAccountProviderPresenterTest.accountProvider1,
@@ -64,27 +62,27 @@ class ChooseAccountProviderViewTest {
eventSink = eventSink,
),
)
- rule.onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick()
+ onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick()
eventSink.assertSingle(ChooseAccountProviderEvents.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1))
}
@Test
- fun `when error is displayed - closing the dialog emits the expected event`() {
+ fun `when error is displayed - closing the dialog emits the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
- rule.setChooseAccountProviderView(
+ setChooseAccountProviderView(
state = aChooseAccountProviderState(
loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink,
),
)
- rule.clickOn(CommonStrings.action_ok)
+ clickOn(CommonStrings.action_ok)
eventSink.assertSingle(ChooseAccountProviderEvents.ClearError)
}
- private fun AndroidComposeTestRule.setChooseAccountProviderView(
+ private fun AndroidComposeUiTest.setChooseAccountProviderView(
state: ChooseAccountProviderState,
onBackClick: () -> Unit = EnsureNeverCalled(),
- onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),
+ onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
@@ -93,7 +91,7 @@ class ChooseAccountProviderViewTest {
ChooseAccountProviderView(
state = state,
onBackClick = onBackClick,
- onOidcDetails = onOidcDetails,
+ onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
onCreateAccountContinue = onCreateAccountContinue,
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
index 6372841250..a9045ab152 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt
@@ -22,9 +22,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
-import io.element.android.libraries.oidc.api.OidcAction
-import io.element.android.libraries.oidc.api.OidcActionFlow
-import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
+import io.element.android.libraries.oauth.api.OAuthAction
+import io.element.android.libraries.oauth.api.OAuthActionFlow
+import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
@@ -74,7 +74,7 @@ class ConfirmAccountProviderPresenterTest {
fun `present - continue oidc`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
@@ -89,21 +89,21 @@ class ConfirmAccountProviderPresenterTest {
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
+ assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
}
}
@Test
- fun `present - oidc - cancel with failure`() = runTest {
+ fun `present - OAuth - cancel with failure`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
- val defaultOidcActionFlow = FakeOidcActionFlow()
+ val defaultOAuthActionFlow = FakeOAuthActionFlow()
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
- defaultOidcActionFlow = defaultOidcActionFlow,
+ defaultOAuthActionFlow = defaultOAuthActionFlow,
)
presenter.test {
val initialState = awaitItem()
@@ -114,25 +114,25 @@ class ConfirmAccountProviderPresenterTest {
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
- authenticationService.givenOidcCancelError(AN_EXCEPTION)
- defaultOidcActionFlow.post(OidcAction.GoBack())
+ assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ authenticationService.givenOAuthCancelError(AN_EXCEPTION)
+ defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFailureState = awaitItem()
assertThat(cancelFailureState.loginMode).isInstanceOf(AsyncData.Failure::class.java)
}
}
@Test
- fun `present - oidc - cancel with success`() = runTest {
+ fun `present - OAuth - cancel with success`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
- val defaultOidcActionFlow = FakeOidcActionFlow()
+ val defaultOAuthActionFlow = FakeOAuthActionFlow()
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
- defaultOidcActionFlow = defaultOidcActionFlow,
+ defaultOAuthActionFlow = defaultOAuthActionFlow,
)
presenter.test {
val initialState = awaitItem()
@@ -143,24 +143,24 @@ class ConfirmAccountProviderPresenterTest {
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
- defaultOidcActionFlow.post(OidcAction.GoBack())
+ assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ defaultOAuthActionFlow.post(OAuthAction.GoBack())
val cancelFinalState = awaitItem()
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
}
}
@Test
- fun `present - oidc - cancel to unblock`() = runTest {
+ fun `present - OAuth - cancel to unblock`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
- val defaultOidcActionFlow = FakeOidcActionFlow()
+ val defaultOAuthActionFlow = FakeOAuthActionFlow()
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
- defaultOidcActionFlow = defaultOidcActionFlow,
+ defaultOAuthActionFlow = defaultOAuthActionFlow,
)
presenter.test {
val initialState = awaitItem()
@@ -168,23 +168,23 @@ class ConfirmAccountProviderPresenterTest {
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
- defaultOidcActionFlow.post(OidcAction.GoBack(toUnblock = true))
+ defaultOAuthActionFlow.post(OAuthAction.GoBack(toUnblock = true))
val cancelFinalState = awaitItem()
assertThat(cancelFinalState.loginMode).isInstanceOf(AsyncData.Uninitialized::class.java)
}
}
@Test
- fun `present - oidc - success with failure`() = runTest {
+ fun `present - OAuth - success with failure`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
- val defaultOidcActionFlow = FakeOidcActionFlow()
+ val defaultOAuthActionFlow = FakeOAuthActionFlow()
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
- defaultOidcActionFlow = defaultOidcActionFlow,
+ defaultOAuthActionFlow = defaultOAuthActionFlow,
)
presenter.test {
val initialState = awaitItem()
@@ -195,9 +195,9 @@ class ConfirmAccountProviderPresenterTest {
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
+ assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
authenticationService.givenLoginError(AN_EXCEPTION)
- defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
+ defaultOAuthActionFlow.post(OAuthAction.Success("aUrl"))
val cancelLoadingState = awaitItem()
assertThat(cancelLoadingState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
val cancelFailureState = awaitItem()
@@ -206,16 +206,16 @@ class ConfirmAccountProviderPresenterTest {
}
@Test
- fun `present - oidc - success with success`() = runTest {
+ fun `present - OAuth - success with success`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
- val defaultOidcActionFlow = FakeOidcActionFlow()
+ val defaultOidcActionFlow = FakeOAuthActionFlow()
val presenter = createConfirmAccountProviderPresenter(
matrixAuthenticationService = authenticationService,
- defaultOidcActionFlow = defaultOidcActionFlow,
+ defaultOAuthActionFlow = defaultOidcActionFlow,
)
presenter.test {
val initialState = awaitItem()
@@ -226,8 +226,8 @@ class ConfirmAccountProviderPresenterTest {
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
- defaultOidcActionFlow.post(OidcAction.Success("aUrl"))
+ assertThat(successState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
+ defaultOidcActionFlow.post(OAuthAction.Success("aUrl"))
val successSuccessState = awaitItem()
assertThat(successSuccessState.loginMode).isInstanceOf(AsyncData.Loading::class.java)
}
@@ -311,10 +311,10 @@ class ConfirmAccountProviderPresenterTest {
}
@Test
- fun `present - confirm account creation with oidc is successful`() = runTest {
+ fun `present - confirm account creation with OAuth is successful`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
@@ -327,16 +327,16 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
+ assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
}
}
@Test
- fun `present - confirm account creation with oidc and url continues with oidc`() = runTest {
+ fun `present - confirm account creation with OAuth and url continues with OAuth`() = runTest {
val aUrl = "aUrl"
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
- Result.success(aMatrixHomeServerDetails(supportsOidcLogin = true))
+ Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true))
},
)
val presenter = createConfirmAccountProviderPresenter(
@@ -350,12 +350,12 @@ class ConfirmAccountProviderPresenterTest {
skipItems(1) // Loading
val submittedState = awaitItem()
assertThat(submittedState.loginMode).isInstanceOf(AsyncData.Success::class.java)
- assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.Oidc::class.java)
+ assertThat(submittedState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java)
}
}
@Test
- fun `present - confirm account creation without oidc and with url continuing with url`() = runTest {
+ fun `present - confirm account creation without OAuth and with url continuing with url`() = runTest {
val aUrl = "aUrl"
val authenticationService = FakeMatrixAuthenticationService(
setHomeserverResult = {
@@ -380,14 +380,14 @@ class ConfirmAccountProviderPresenterTest {
params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
- defaultOidcActionFlow: OidcActionFlow = FakeOidcActionFlow(),
+ defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
) = ConfirmAccountProviderPresenter(
params = params,
accountProviderDataSource = accountProviderDataSource,
loginHelper = createLoginHelper(
authenticationService = matrixAuthenticationService,
- oidcActionFlow = defaultOidcActionFlow,
+ oAuthActionFlow = defaultOAuthActionFlow,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
),
)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt
index 26da50da63..c0e7e5c378 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt
@@ -6,20 +6,23 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasText
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_USER_NAME
@@ -30,158 +33,154 @@ 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 org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class LoginPasswordViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on back invoke back callback`() {
+ fun `clicking on back invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `changing login invokes the expected event`() {
+ fun `changing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
- val userNameHint = rule.activity.getString(CommonStrings.common_username)
- rule.onNodeWithText(userNameHint).performTextInput(A_USER_NAME)
+ val userNameHint = activity!!.getString(CommonStrings.common_username)
+ onNodeWithText(userNameHint).performTextInput(A_USER_NAME)
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin(A_USER_NAME)
)
}
@Test
- fun `changing login removes new lines the expected event`() {
+ fun `changing login removes new lines the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
- val userNameHint = rule.activity.getString(CommonStrings.common_username)
- rule.onNodeWithText(userNameHint).performTextInput("a\nb")
+ val userNameHint = activity!!.getString(CommonStrings.common_username)
+ onNodeWithText(userNameHint).performTextInput("a\nb")
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("ab")
)
}
@Test
- fun `clearing login invokes the expected event`() {
+ fun `clearing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(A_USER_NAME),
eventSink = eventsRecorder,
),
)
- val a11yClear = rule.activity.getString(CommonStrings.action_clear)
- rule.onNodeWithContentDescription(a11yClear).performClick()
+ val a11yClear = activity!!.getString(CommonStrings.action_clear)
+ onNodeWithContentDescription(a11yClear).performClick()
eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("")
)
}
@Test
- fun `changing password invokes the expected event`() {
+ fun `changing password invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
eventSink = eventsRecorder,
),
)
- val userNameHint = rule.activity.getString(CommonStrings.common_password)
- rule.onNodeWithText(userNameHint).performTextInput(A_PASSWORD)
+ val userNameHint = activity!!.getString(CommonStrings.common_password)
+ onNodeWithText(userNameHint).performTextInput(A_PASSWORD)
eventsRecorder.assertSingle(
LoginPasswordEvents.SetPassword(A_PASSWORD)
)
}
@Test
- fun `reveal password makes the password visible`() {
+ fun `reveal password makes the password visible`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(password = A_PASSWORD),
eventSink = eventsRecorder,
),
)
- rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
+ onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
+ val resources = activity!!.resources
// Show password
- val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
- rule.onNodeWithContentDescription(a11yShowPassword).performClick()
- rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
+ val a11yShowPassword = resources.getString(CommonStrings.a11y_show_password)
+ onNodeWithContentDescription(a11yShowPassword).performClick()
+ onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
// Hide password
- val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
- rule.onNodeWithContentDescription(a11yHidePassword).performClick()
- rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
+ val a11yHidePassword = resources.getString(CommonStrings.a11y_hide_password)
+ onNodeWithContentDescription(a11yHidePassword).performClick()
+ onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
}
@Test
- fun `when login is empty, continue button is not enabled`() {
+ fun `when login is empty, continue button is not enabled`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(password = A_PASSWORD),
eventSink = eventsRecorder,
),
)
- val continueStr = rule.activity.getString(CommonStrings.action_continue)
- rule.onNodeWithText(continueStr).assertIsNotEnabled()
+ val continueStr = activity!!.getString(CommonStrings.action_continue)
+ onNodeWithText(continueStr).assertIsNotEnabled()
}
@Test
- fun `when password is empty, continue button is not enabled`() {
+ fun `when password is empty, continue button is not enabled`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(login = A_USER_NAME),
eventSink = eventsRecorder,
),
)
- val continueStr = rule.activity.getString(CommonStrings.action_continue)
- rule.onNodeWithText(continueStr).assertIsNotEnabled()
+ val continueStr = activity!!.getString(CommonStrings.action_continue)
+ onNodeWithText(continueStr).assertIsNotEnabled()
}
@Config(qualifiers = "h1024dp")
@Test
- fun `clicking on Continue sends expected event`() {
+ fun `clicking on Continue sends expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLoginPasswordView(
+ setLoginPasswordView(
aLoginPasswordState(
formState = aLoginFormState(login = A_USER_NAME, password = A_PASSWORD),
eventSink = eventsRecorder,
),
)
- val continueStr = rule.activity.getString(CommonStrings.action_continue)
- rule.onNodeWithText(continueStr).assertIsEnabled()
- rule.clickOn(CommonStrings.action_continue)
+ val continueStr = activity!!.getString(CommonStrings.action_continue)
+ onNodeWithText(continueStr).assertIsEnabled()
+ clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(
LoginPasswordEvents.Submit
)
}
}
-private fun AndroidComposeTestRule.setLoginPasswordView(
+private fun AndroidComposeUiTest.setLoginPasswordView(
state: LoginPasswordState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
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 1fdfb7e070..8249694278 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
@@ -31,8 +31,8 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
-import io.element.android.libraries.oidc.api.OidcActionFlow
-import io.element.android.libraries.oidc.test.customtab.FakeOidcActionFlow
+import io.element.android.libraries.oauth.api.OAuthActionFlow
+import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -312,11 +312,11 @@ private fun createPresenter(
)
fun createLoginHelper(
- oidcActionFlow: OidcActionFlow = FakeOidcActionFlow(),
+ oAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(),
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
): LoginHelper = LoginHelper(
- oidcActionFlow = oidcActionFlow,
+ oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService,
webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever,
)
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt
index ad09445075..bcb62ea707 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt
@@ -6,20 +6,23 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.onboarding
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues
import com.google.testing.junit.testparameterinjector.TestParameter
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginMode
import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.matrix.api.auth.OidcDetails
+import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -29,22 +32,17 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestParameterInjector
@RunWith(RobolectricTestParameterInjector::class)
class OnboardingViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `when can create account - clicking on create account calls the expected callback`() {
+ fun `when can create account - clicking on create account calls the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
canCreateAccount = true,
showDeveloperSettings = false,
@@ -52,40 +50,40 @@ class OnboardingViewTest {
),
onCreateAccount = callback,
)
- rule.clickOn(R.string.screen_onboarding_sign_up)
+ clickOn(R.string.screen_onboarding_sign_up)
// Developer settings should not be shown
- val developerSettingsText = rule.activity.getString(CommonStrings.common_developer_options)
- rule.onNodeWithContentDescription(developerSettingsText).assertDoesNotExist()
+ val developerSettingsText = activity!!.getString(CommonStrings.common_developer_options)
+ onNodeWithContentDescription(developerSettingsText).assertDoesNotExist()
}
}
@Test
- fun `when can go back - clicking on back calls the expected callback`() {
+ fun `when can go back - clicking on back calls the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
isAddingAccount = true,
eventSink = eventSink,
),
onBackClick = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() {
+ fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = true,
eventSink = eventSink,
),
onSignInWithQrCode = callback,
)
- rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
+ clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
}
}
@@ -95,10 +93,10 @@ class OnboardingViewTest {
"can search account provider" to false,
"cannot search account provider" to true,
)
- ) {
+ ) = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider,
@@ -106,7 +104,7 @@ class OnboardingViewTest {
),
onSignIn = callback,
)
- rule.clickOn(R.string.screen_onboarding_sign_in_manually)
+ clickOn(R.string.screen_onboarding_sign_in_manually)
}
}
@@ -116,10 +114,10 @@ class OnboardingViewTest {
"can search account provider" to false,
"cannot search account provider" to true,
)
- ) {
+ ) = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
canLoginWithQrCode = false,
canCreateAccount = false,
@@ -128,89 +126,89 @@ class OnboardingViewTest {
),
onSignIn = callback,
)
- rule.clickOn(CommonStrings.action_continue)
+ clickOn(CommonStrings.action_continue)
}
}
@Test
- fun `when sign in to pre defined account provider - clicking on button emits the expected event`() {
+ fun `when sign in to pre defined account provider - clicking on button emits the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
eventSink = eventSink,
),
)
- val buttonText = rule.activity.getString(R.string.screen_onboarding_sign_in_to, "element.io")
- rule.onNodeWithText(buttonText).performClick()
+ val buttonText = activity!!.getString(R.string.screen_onboarding_sign_in_to, "element.io")
+ onNodeWithText(buttonText).performClick()
eventSink.assertSingle(OnBoardingEvents.OnSignIn("element.io"))
}
@Test
- fun `when error is displayed - closing the dialog emits the expected event`() {
+ fun `when error is displayed - closing the dialog emits the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
defaultAccountProvider = "element.io",
loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink,
),
)
- rule.clickOn(CommonStrings.action_ok)
+ clickOn(CommonStrings.action_ok)
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
@Test
- fun `clicking on report a problem calls the sign in callback`() {
+ fun `clicking on report a problem calls the sign in callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
canReportBug = true,
eventSink = eventSink,
),
onReportProblem = callback,
)
- val text = rule.activity.getString(CommonStrings.common_report_a_problem)
- rule.onNodeWithText(text).assertExists()
- rule.clickOn(CommonStrings.common_report_a_problem)
+ val text = activity!!.getString(CommonStrings.common_report_a_problem)
+ onNodeWithText(text).assertExists()
+ clickOn(CommonStrings.common_report_a_problem)
}
}
@Test
- fun `clicking on settings calls the developer settings callback`() {
+ fun `clicking on settings calls the developer settings callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
showDeveloperSettings = true,
eventSink = eventSink,
),
onDeveloperSettingsClick = callback,
)
- val text = rule.activity.getString(CommonStrings.common_developer_options)
- rule.onNodeWithContentDescription(text).performClick()
+ val text = activity!!.getString(CommonStrings.common_developer_options)
+ onNodeWithContentDescription(text).performClick()
}
}
@Test
- fun `cannot report a problem when the feature is disabled`() {
+ fun `cannot report a problem when the feature is disabled`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder(expectEvents = false)
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
canReportBug = false,
eventSink = eventSink,
),
)
- val text = rule.activity.getString(CommonStrings.common_report_a_problem)
- rule.onNodeWithText(text).assertDoesNotExist()
+ val text = activity!!.getString(CommonStrings.common_report_a_problem)
+ onNodeWithText(text).assertDoesNotExist()
}
@Test
- fun `when success PasswordLogin - the expected callback is invoked and the event is received`() {
+ fun `when success PasswordLogin - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
ensureCalledOnce { callback ->
- rule.setOnboardingView(
+ setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.PasswordLogin),
eventSink = eventSink,
@@ -222,27 +220,27 @@ class OnboardingViewTest {
}
@Test
- fun `when success Oidc - the expected callback is invoked and the event is received`() {
+ fun `when success Oidc - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
- val oidcDetails = OidcDetails("aUrl")
- ensureCalledOnceWithParam(oidcDetails) { callback ->
- rule.setOnboardingView(
+ val oAuthDetails = OAuthDetails("aUrl")
+ ensureCalledOnceWithParam(oAuthDetails) { callback ->
+ setOnboardingView(
state = anOnBoardingState(
- loginMode = AsyncData.Success(LoginMode.Oidc(oidcDetails)),
+ loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails)),
eventSink = eventSink,
),
- onOidcDetails = callback,
+ onOAuthDetails = callback,
)
}
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
@Test
- fun `when success AccountCreation - the expected callback is invoked and the event is received`() {
+ fun `when success AccountCreation - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder()
- val oidcDetails = OidcDetails("aUrl")
- ensureCalledOnceWithParam(oidcDetails.url) { callback ->
- rule.setOnboardingView(
+ val oAuthDetails = OAuthDetails("aUrl")
+ ensureCalledOnceWithParam(oAuthDetails.url) { callback ->
+ setOnboardingView(
state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")),
eventSink = eventSink,
@@ -253,7 +251,7 @@ class OnboardingViewTest {
eventSink.assertSingle(OnBoardingEvents.ClearError)
}
- private fun AndroidComposeTestRule.setOnboardingView(
+ private fun AndroidComposeUiTest.setOnboardingView(
state: OnBoardingState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(),
@@ -261,7 +259,7 @@ class OnboardingViewTest {
onSignIn: (Boolean) -> Unit = EnsureNeverCalledWithParam(),
onCreateAccount: () -> Unit = EnsureNeverCalled(),
onReportProblem: () -> Unit = EnsureNeverCalled(),
- onOidcDetails: (OidcDetails) -> Unit = EnsureNeverCalledWithParam(),
+ onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(),
@@ -275,7 +273,7 @@ class OnboardingViewTest {
onSignIn = onSignIn,
onCreateAccount = onCreateAccount,
onReportProblem = onReportProblem,
- onOidcDetails = onOidcDetails,
+ onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
onCreateAccountContinue = onCreateAccountContinue,
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt
index a0469a684e..79566625c5 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt
@@ -6,49 +6,47 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.qrcode.confirmation
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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 QrCodeConfirmationViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `on back pressed - calls the expected callback`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeConfirmationView(
+ setQrCodeConfirmationView(
step = QrCodeConfirmationStep.DisplayCheckCode("12"),
onCancel = callback
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on Cancel button clicked - calls the expected callback`() {
+ fun `on Cancel button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeConfirmationView(
+ setQrCodeConfirmationView(
step = QrCodeConfirmationStep.DisplayVerificationCode("123456"),
onCancel = callback
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
}
}
- private fun AndroidComposeTestRule.setQrCodeConfirmationView(
+ private fun AndroidComposeUiTest.setQrCodeConfirmationView(
step: QrCodeConfirmationStep,
onCancel: () -> Unit
) {
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt
index de0f689220..2ae68c3485 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.qrcode.error
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
import io.element.android.libraries.ui.strings.CommonStrings
@@ -18,47 +21,42 @@ import io.element.android.tests.testutils.EnsureNeverCalled
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 QrCodeErrorViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `on back pressed - calls the onCancel callback`() {
+ fun `on back pressed - calls the onCancel callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeErrorView(
+ setQrCodeErrorView(
onCancel = callback,
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on try again button clicked - calls the expected callback`() {
+ fun `on try again button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeErrorView(
+ setQrCodeErrorView(
onRetry = callback,
)
- rule.clickOn(CommonStrings.action_try_again)
+ clickOn(CommonStrings.action_try_again)
}
}
@Test
- fun `on cancel button clicked - calls the expected callback`() {
+ fun `on cancel button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeErrorView(
+ setQrCodeErrorView(
onCancel = callback,
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
}
}
- private fun AndroidComposeTestRule.setQrCodeErrorView(
+ private fun AndroidComposeUiTest.setQrCodeErrorView(
onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError,
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt
index cec67e5011..c6812d3759 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.qrcode.intro
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -19,42 +22,37 @@ 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 QrCodeIntroViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
@Test
- fun `on back pressed - calls the expected callback`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeIntroView(
+ setQrCodeIntroView(
state = aQrCodeIntroState(),
onBackClicked = callback
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on back button clicked - calls the expected callback`() {
+ fun `on back button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeIntroView(
+ setQrCodeIntroView(
state = aQrCodeIntroState(),
onBackClicked = callback
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `when can continue - calls the expected callback`() {
+ fun `when can continue - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeIntroView(
+ setQrCodeIntroView(
state = aQrCodeIntroState(canContinue = true),
onContinue = callback
)
@@ -62,16 +60,16 @@ class QrCodeIntroViewTest {
}
@Test
- fun `on submit button clicked - emits the Continue event`() {
+ fun `on submit button clicked - emits the Continue event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder()
- rule.setQrCodeIntroView(
+ setQrCodeIntroView(
state = aQrCodeIntroState(eventSink = eventRecorder),
)
- rule.clickOn(R.string.screen_qr_code_login_initial_state_button_title)
+ clickOn(R.string.screen_qr_code_login_initial_state_button_title)
eventRecorder.assertSingle(QrCodeIntroEvents.Continue)
}
- private fun AndroidComposeTestRule.setQrCodeIntroView(
+ private fun AndroidComposeUiTest.setQrCodeIntroView(
state: QrCodeIntroState,
onBackClicked: () -> Unit = EnsureNeverCalled(),
onContinue: () -> Unit = EnsureNeverCalled(),
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt
index b8becd545f..bde960ef1a 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt
@@ -6,12 +6,15 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.activity.ComponentActivity
import androidx.camera.lifecycle.ProcessCameraProvider
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.architecture.AsyncAction
@@ -24,16 +27,11 @@ import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import org.junit.After
import org.junit.Before
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class QrCodeScanViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
-
private var provider: ProcessCameraProvider? = null
@Before
@@ -48,28 +46,28 @@ class QrCodeScanViewTest {
}
@Test
- fun `on back pressed - calls the expected callback`() {
+ fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback ->
- rule.setQrCodeScanView(
+ setQrCodeScanView(
state = aQrCodeScanState(),
onBackClick = callback
)
- rule.pressBackKey()
+ pressBackKey()
}
}
@Test
- fun `on QR code data ready - calls the expected callback`() {
+ fun `on QR code data ready - calls the expected callback`() = runAndroidComposeUiTest {
val data = FakeMatrixQrCodeLoginData()
ensureCalledOnceWithParam(data) { callback ->
- rule.setQrCodeScanView(
+ setQrCodeScanView(
state = aQrCodeScanState(authenticationAction = AsyncAction.Success(data)),
onQrCodeDataReady = callback
)
}
}
- private fun AndroidComposeTestRule.setQrCodeScanView(
+ private fun AndroidComposeUiTest.setQrCodeScanView(
state: QrCodeScanState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit = EnsureNeverCalledWithParam(),
diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts
index 8de7718980..d5356ced63 100644
--- a/features/logout/impl/build.gradle.kts
+++ b/features/logout/impl/build.gradle.kts
@@ -35,6 +35,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
+ implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.workmanager.api)
api(projects.features.logout.api)
diff --git a/features/logout/impl/src/main/res/values-ca/translations.xml b/features/logout/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..eb0c82bb15
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,17 @@
+
+
+ "Segur que vols tancar sessió?"
+ "Tanca sessió"
+ "Tanca sessió"
+ "S\'està tancant la sessió…"
+ "Estàs a punt de tancar sessió. Si tanques sessió ara, perdràs l\'accés als missatges xifrats."
+ "Has desactivat la còpia de seguretat"
+ "Encara tens una còpia de seguretat de les teves claus i t\'has desconnectat. Torna a connectar-te per poder fer una còpia de seguretat de les teves claus abans de tancar la sessió."
+ "Encara s\'està fent una còpia de seguretat de les teves claus"
+ "Espera a que s\'hagi completat abans de tancar sessió."
+ "Encara s\'està fent una còpia de seguretat de les teves claus"
+ "Tanca sessió"
+ "Estàs a punt de tancar sessió a la teva última i única sessió. Si tanques sessió ara, perdràs l\'accés als missatges xifrats."
+ "Recuperació no configurada"
+ "Estàs a punt de tancar sessió. Si tanques sessió ara, pot ser que perdis l\'accés als missatges xifrats."
+
diff --git a/features/logout/impl/src/main/res/values-de/translations.xml b/features/logout/impl/src/main/res/values-de/translations.xml
index 8ebea45c0e..0218642abc 100644
--- a/features/logout/impl/src/main/res/values-de/translations.xml
+++ b/features/logout/impl/src/main/res/values-de/translations.xml
@@ -1,18 +1,18 @@
- "Möchtest du dich wirklich abmelden?"
- "Abmelden"
- "Abmelden"
- "Abmelden…"
- "Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn dich jetzt abmeldest, verlierst du den Zugriff auf deine verschlüsselten Nachrichten."
- "Du hast das Backup deaktiviert"
- "Das Backup deiner Schlüssel lief noch, als du offline gegangen bist. Verbinde dich erneut, damit deine Schlüssel vor dem Abmelden gesichert werden können."
+ "Bist du sicher, dass du dieses Gerät entfernen möchtest?"
+ "Dieses Gerät entfernen"
+ "Dieses Gerät entfernen"
+ "Gerät wird entfernt…"
+ "Dies ist dein einziges Gerät. Wenn du es entfernst, benötigst du einen Wiederherstellungsschlüssel, um deine digitale Identität zu bestätigen und deine verschlüsselten Chats bei deiner nächsten Anmeldung wiederherzustellen."
+ "Du bist dabei, den Zugriff auf deine verschlüsselten Chats zu verlieren"
+ "Deine Schlüssel wurden noch gesichert, während du offline gegangen bist. Stelle die Verbindung wieder her, damit deine Schlüssel gesichert werden können, bevor du dieses Gerät entfernst.""Deine Schlüssel werden noch gesichert"
- "Bitte warte, bis dieser Vorgang abgeschlossen ist, bevor du dich abmeldest."
+ "Bitte warte, bis der Vorgang abgeschlossen ist, bevor du dieses Gerät entfernst.""Deine Schlüssel werden noch gesichert"
- "Abmelden"
- "Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn du dich jetzt abmeldest, verlierst du den Zugriff auf deine verschlüsselten Nachrichten."
- "Wiederherstellung nicht eingerichtet"
- "Du bist dabei, dich von deiner letzten Sitzung abzumelden. Wenn du dich jetzt abmeldest, verlierst du möglicherweise den Zugriff auf deine verschlüsselten Nachrichten."
- "Hast du deinen Wiederherstellungsschlüssel gespeichert?"
+ "Dieses Gerät entfernen"
+ "Dies ist dein einziges Gerät. Wenn du es entfernst, benötigst du einen Wiederherstellungsschlüssel, um deine digitale Identität zu bestätigen und deine verschlüsselten Chats bei deiner nächsten Anmeldung wiederherzustellen."
+ "Du bist dabei, den Zugriff auf deine verschlüsselten Chats zu verlieren"
+ "Dies ist dein einziges Gerät. Wenn du es entfernst, benötigst du einen Wiederherstellungsschlüssel, um deine digitale Identität zu bestätigen und deine verschlüsselten Chats bei deiner nächsten Anmeldung wiederherzustellen."
+ "Stelle sicher, dass du Zugriff auf deinen Wiederherstellungsschlüssel hast, bevor du dieses Gerät entfernst"
diff --git a/features/logout/impl/src/main/res/values-et/translations.xml b/features/logout/impl/src/main/res/values-et/translations.xml
index 4bdf169576..cd29e13354 100644
--- a/features/logout/impl/src/main/res/values-et/translations.xml
+++ b/features/logout/impl/src/main/res/values-et/translations.xml
@@ -1,18 +1,18 @@
- "Kas sa oled kindel, et soovid välja logida?"
- "Logi välja"
- "Logi välja"
- "Logime välja…"
- "Oled oma viimasest seansist välja logimas. Kui logid nüüd välja, kaotad ligipääsu oma krüptitud sõnumitele."
- "Sa oled varukoopiate tegemise välja lülitanud"
- "Kui su võrguühendus katkes, siis sinu krüptovõtmed oli parasjagu varundamisel. Loo võrguühendus uuesti, oota kuni krüptovõtmete varundamine lõppeb ja alles siis logi rakendusest välja."
+ "Kas sa oled kindel, et soovid selle seadme eemaldada?"
+ "Eemalda see seade"
+ "Eemalda see seade"
+ "Eemaldan seadet…"
+ "See on sinu ainus seade. Kui sa selle eemaldad, vajad taastamisvõtit, et kinnitada oma digitaalset identiteeti ja taastada järgmisel sisselogimisel oma krüptitud vestlused."
+ "Sa kaotad peagi juurdepääsu oma krüptitud vestlustele"
+ "Kui su võrguühendus katkes, siis sinu krüptovõtmed oli parasjagu varundamisel. Loo võrguühendus uuesti, oota kuni krüptovõtmete varundamine lõppeb ja alles siis eemalda see seade.""Sinu krüptovõtmed on veel varundamisel"
- "Enne väljalogimist palun oota, et pooleliolev toiming lõppeb."
+ "Enne selle seadme eemaldamist palun oota, et pooleliolev toiming lõppeb.""Sinu krüptovõtmed on veel varundamisel"
- "Logi välja"
- "Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis kaotad ligipääsu oma krüptitud sõnumitele."
+ "Eemalda see seade"
+ "See on sinu ainus seade. Kui sa selle eemaldad, vajad taastamisvõtit, et kinnitada oma digitaalset identiteeti ja taastada järgmisel sisselogimisel oma krüptitud vestlused.""Andmete taastamine on seadistamata""Sa oled logimas välja oma viimasest sessioonist. Kui teed seda nüüd, siis ilmselt kaotad ligipääsu oma krüptitud sõnumitele."
- "Kas sa oled oma taastevõtme salvestanud?"
+ "Enne selle seadme eemaldamist veendu, et sul on juurdepääs taastevõtmele"
diff --git a/features/logout/impl/src/main/res/values-fa/translations.xml b/features/logout/impl/src/main/res/values-fa/translations.xml
index 8dfaad8580..a540b9be37 100644
--- a/features/logout/impl/src/main/res/values-fa/translations.xml
+++ b/features/logout/impl/src/main/res/values-fa/translations.xml
@@ -1,18 +1,18 @@
- "مطمئنید که میخواهید از حسابتان خارج شوید؟"
- "خروج"
- "خروج"
- "خارج شدن…"
- "دارید از واپسین نشستتان خارج میشوید. اگر اکنون خارج شوید پیامهای رمزنگاشتهتان را از دست خواهید داد."
- "پشتیبان را خاموش کردهاید"
- "در هنگامی که آفلاین شدید، کلیدهای شما هنوز در حال پشتیبانگیری بودند. دوباره متصل شوید ، تا قبل از خروج از کلیدهایتان نسخه پشتیبان گرفته شود."
+ "مطمئنید که میخواهید این افزاره را بردارید؟"
+ "برداشتن این افزاره"
+ "برداشتن این افزاره"
+ "برداشتن افزاره…"
+ "این تنها دستگاه شماست. اگر آن را جدا کنید، برای تأیید هویت دیجیتال خود و بازیابی چتهای رمزگذاری شدهتان در دفعه بعد که وارد سیستم میشوید، به یک کلید بازیابی نیاز خواهید داشت."
+ "شما در درحال از دست دادن دسترسی به چتهای رمزگذاریشدهتان هستید."
+ "وقتی آفلاین شدید، کلیدهای شما هنوز در حال پشتیبانگیری بودند. دوباره متصل شوید تا قبل از جدا کردن این دستگاه، از کلیدهایتان پشتیبانگیری شود.""کلیدهایتان هنوز در حال پشتیبان گیریند"
- "لطفاً پیش از خروج منتظر پایانش شوید."
+ "لطفاً قبل از خروج از این دستگاه، منتظر بمانید تا این مراحل تکمیل شود.""کلیدهایتان هنوز در حال پشتیبان گیریند"
- "خروج"
- "شما در آستانه خروج از آخرین جلسه خود هستید. اگر اکنون از سیستم خارج شوید، دسترسی به پیام های رمزگذاری شده تان را از دست خواهید داد."
- "بازگردانی برپا نشده"
- "دارید از واپسین نشستتان خارج میشوید. اگر اکنون خارج شوید ممکن است پیامهای رمزنگاشتهتان را از دست بدهید."
- "کلید بازیابیتان را ذخیره کردهاید؟"
+ "برداشتن این افزاره"
+ "این تنها دستگاه شماست. اگر آن را جدا کنید، برای تأیید هویت دیجیتال خود و بازیابی چتهای رمزگذاری شدهتان در دفعه بعد که وارد سیستم میشوید، به یک کلید بازیابی نیاز خواهید داشت."
+ "شما در حال از دست دادن دسترسی به چتهای رمزگذاریشدهتان هستید."
+ "این تنها دستگاه شماست. اگر آن را جدا کنید، برای تأیید هویت دیجیتال خود و بازیابی چتهای رمزگذاری شدهتان در دفعه بعد که وارد سیستم میشوید، به یک کلید بازیابی نیاز خواهید داشت."
+ "قبل از حذف این دستگاه، مطمئن شوید که به کلید بازیابی خود دسترسی دارید."
diff --git a/features/logout/impl/src/main/res/values-hr/translations.xml b/features/logout/impl/src/main/res/values-hr/translations.xml
index 0a5d583a3c..ec8116a8c5 100644
--- a/features/logout/impl/src/main/res/values-hr/translations.xml
+++ b/features/logout/impl/src/main/res/values-hr/translations.xml
@@ -1,17 +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"
+ "Jeste li sigurni da želite ukloniti ovaj uređaj?"
+ "Ukloni ovaj uređaj"
+ "Ukloni ovaj uređaj"
+ "Uklanjanje uređaja…"
+ "Ovo je vaš jedini uređaj. Ako ga uklonite, trebat će vam ključ za oporavak kako biste potvrdili svoj digitalni identitet i vratili šifrirane razgovore sljedeći put kada se prijavite."
+ "Izgubiti ćete pristup svojim šifriranim chatovima""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."
+ "Pričekajte da se ovo završi prije uklanjanja ovog uređaja.""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."
+ "Ukloni ovaj uređaj"
+ "Ovo je vaš jedini uređaj. Ako ga uklonite, trebat će vam ključ za oporavak kako biste potvrdili svoj digitalni identitet i vratili šifrirane razgovore sljedeći put kada se prijavite."
+ "Izgubit ćete pristup svojim šifriranim chatovima"
+ "Ovo je vaš jedini uređaj. Ako ga uklonite, trebat će vam ključ za oporavak kako biste potvrdili svoj digitalni identitet i vratili šifrirane razgovore sljedeći put kada se prijavite."
+ "Prije uklanjanja ovog uređaja provjerite imate li pristup ključu za oporavak"
diff --git a/features/logout/impl/src/main/res/values-in/translations.xml b/features/logout/impl/src/main/res/values-in/translations.xml
index dabf83545e..9d63573973 100644
--- a/features/logout/impl/src/main/res/values-in/translations.xml
+++ b/features/logout/impl/src/main/res/values-in/translations.xml
@@ -1,16 +1,16 @@
- "Apakah Anda yakin ingin keluar dari akun?"
- "Keluar dari akun"
- "Keluar dari akun"
- "Mengeluarkan dari akun…"
+ "Apakah Anda yakin ingin non aktifkan device dari akun?"
+ "Hapus device dari akun"
+ "Hapus device dari akun"
+ "Mengeluarkan device dari akun…""Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda.""Anda telah menonaktifkan pencadangan""Kunci Anda masih dicadangkan saat Anda luring. Sambungkan kembali sehingga kunci Anda dapat dicadangkan sebelum keluar.""Kunci Anda masih dicadangkan""Mohon tunggu hingga proses ini selesai sebelum keluar.""Kunci Anda masih dicadangkan"
- "Keluar dari akun"
+ "Hapus device dari akun""Anda akan keluar dari sesi Anda yang terakhir. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda.""Pemulihan belum disiapkan""Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda mungkin kehilangan akses ke pesan terenkripsi Anda."
diff --git a/features/logout/impl/src/main/res/values-pl/translations.xml b/features/logout/impl/src/main/res/values-pl/translations.xml
index 46a5c2d6bd..691255f434 100644
--- a/features/logout/impl/src/main/res/values-pl/translations.xml
+++ b/features/logout/impl/src/main/res/values-pl/translations.xml
@@ -1,18 +1,18 @@
- "Czy na pewno chcesz się wylogować?"
- "Wyloguj"
- "Wyloguj"
- "Wylogowywanie…"
- "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
- "Wyłączyłeś backup"
- "Twoje klucze były nadal archiwizowane po przejściu w tryb offline. Połącz się ponownie, aby zapisać w chmurze przed wylogowaniem."
+ "Czy na pewno chcesz usunąć to urządzenie?"
+ "Usuń to urządzenie"
+ "Usuń to urządzenie"
+ "Usuwam urządzenie…"
+ "To jest twoje jedyne urządzenie. Jeśli je usuniesz, będziesz potrzebować klucza przywracania, aby potwierdzić swoją tożsamość cyfrową i przywrócić zaszyfrowane czaty przy następnym logowaniu."
+ "Zamierzasz utracić dostęp do swoich zaszyfrowanych czatów"
+ "Twoje klucze były nadal archiwizowane po przejściu w tryb offline. Połącz się ponownie, aby zapisać je w chmurze przed usunięciem urządzenia.""Twoje klucze są nadal archiwizowane"
- "Zanim się wylogujesz, poczekaj na zakończenie operacji."
+ "Poczekaj na zakończenie procesu, zanim usuniesz to urządzenie.""Twoje klucze są nadal archiwizowane"
- "Wyloguj"
- "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
- "Nie ustawiono przywracania"
- "Zamierzasz wylogować się ze swojej ostatniej sesji. Jeśli wylogujesz się teraz, stracisz dostęp do swoich wiadomości szyfrowanych."
- "Czy zapisałeś swój klucz przywracania?"
+ "Usuń to urządzenie"
+ "To jest twoje jedyne urządzenie. Jeśli je usuniesz, będziesz potrzebować klucza przywracania, aby potwierdzić swoją tożsamość cyfrową i przywrócić zaszyfrowane czaty przy następnym logowaniu."
+ "Zamierzasz utracić dostęp do swoich zaszyfrowanych czatów"
+ "To jest twoje jedyne urządzenie. Jeśli je usuniesz, będziesz potrzebować klucza przywracania, aby potwierdzić swoją tożsamość cyfrową i przywrócić zaszyfrowane czaty przy następnym logowaniu."
+ "Upewnij się, że posiadasz dostęp do klucza przywracania przed usunięciem urządzenia"
diff --git a/features/logout/impl/src/main/res/values-pt/translations.xml b/features/logout/impl/src/main/res/values-pt/translations.xml
index b8a7161c21..76e4f09d42 100644
--- a/features/logout/impl/src/main/res/values-pt/translations.xml
+++ b/features/logout/impl/src/main/res/values-pt/translations.xml
@@ -1,18 +1,18 @@
- "Tens a certeza que queres terminar a sessão?"
- "Terminar sessão"
- "Terminar sessão"
- "A terminar sessão…"
- "Estás prestes a terminar a tua última sessão. Se continuares, perderás o acesso às tuas mensagens cifradas."
- "Desativaste a cópia de segurança"
- "As tuas chaves ainda estavam a ser guardadas quando ficaste desligado. Volta a ligar-te para que as tuas chaves possam ser guardadas antes de encerrares a sessão."
+ "Tens a certeza que queres remover este dispositivo?"
+ "Remover este dispositivo"
+ "Remover este dispositivo"
+ "A remover dispositivo…"
+ "Este é o teu único dispositivo. Se o removeres, da próxima vez que iniciares sessão, precisarás da chave de recuperação para confirmares a tua identidade digital e recuperares as tuas conversas cifradas."
+ "Estás prestes a perder o acesso às tuas conversas privadas"
+ "As tuas chaves ainda estavam a ser guardadas quando ficaste desligado. Volta a ligar-te para que as tuas chaves possam ser guardadas antes de removeres o dispositivo.""As tuas chaves ainda estão a ser guardadas"
- "Por favor, aguarda a conclusão desta operação antes de terminares a sessão."
+ "Por favor, aguarda a conclusão desta operação antes de removeres o dispositivo.""As tuas chaves ainda estão a ser guardadas"
- "Terminar sessão"
- "Estás prestes a terminar a tua última sessão. Se continuares, perderás o acesso às tuas mensagens cifradas."
- "Recuperação não configurada"
- "Estás prestes a terminar a tua última sessão. Se continuares, poderás perder o acesso às tuas mensagens cifradas."
- "Guardaste a tua chave de recuperação?"
+ "Remover este dispositivo"
+ "Este é o teu único dispositivo. Se o removeres, da próxima vez que iniciares sessão, precisarás da chave de recuperação para confirmares a tua identidade digital e recuperares as tuas conversas cifradas."
+ "Estás prestes a perder o acesso às tuas conversas cifradas"
+ "Este é o teu único dispositivo. Se o removeres, da próxima vez que iniciares sessão, precisarás da chave de recuperação para confirmares a tua identidade digital e recuperares as tuas conversas cifradas."
+ "Certifica-te de que tens acesso à tua chave de recuperação antes de removeres este dispositivo"
diff --git a/features/logout/impl/src/main/res/values-ro/translations.xml b/features/logout/impl/src/main/res/values-ro/translations.xml
index 7124188269..1f1ff9e07a 100644
--- a/features/logout/impl/src/main/res/values-ro/translations.xml
+++ b/features/logout/impl/src/main/res/values-ro/translations.xml
@@ -4,15 +4,15 @@
"Deconectați-vă""Deconectați-vă""Deconectare în curs…"
- "Sunteți pe cale să vă deconectați de la ultima sesiune. Dacă vă deconectați acum, veți pierde accesul la mesajele criptate."
- "Ați dezactivat backup-ul"
- "Cheile dumneavoastră erau încă în curs de backup atunci când ați fost deconectat. Reconectați-vă pentru ca cheile dumneavoastră să poată fi salvate înainte de a vă deconecta."
+ "Acesta este singurul dumneavoastră dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restaura mesajele criptate data viitoare când vă conectați."
+ "Sunteți pe cale să vă pierdeți accesul la mesajele dumneavoastră criptate."
+ "Cheile dumneavoastră erau încă în curs de backup atunci când ați fost deconectat. Reconectați-vă pentru ca cheile dumneavoastră să poată fi salvate înainte de a elimina acest dispozitiv.""Cheile dumneavoastră sunt încă în curs de backup""Vă rugăm să așteptați până la finalizarea acestui proces înainte de a vă deconecta.""Cheile dumneavoastră sunt încă în curs de backup""Deconectați-vă"
- "Sunteți pe cale să vă deconectați de la ultima sesiune. Dacă vă deconectați acum, veți pierde accesul la mesajele criptate."
- "Recuperarea nu este configurată"
- "Sunteți pe cale să vă deconectați de la ultima sesiune. Dacă vă deconectați acum, este posibil să pierdeți accesul la mesajele criptate."
- "Ați salvat cheia de recuperare?"
+ "Acesta este singurul dumneavoastră dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restaura chat-urile criptate data viitoare când vă conectați."
+ "Sunteți pe cale să pierdeți accesul la mesajele dumneavoastră criptate"
+ "Acesta este singurul dumneavoastră dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restaura mesajele criptate data viitoare când vă conectați."
+ "Asigurați-vă că aveți acces la cheia de recuperare înainte de a elimina acest dispozitiv."
diff --git a/features/logout/impl/src/main/res/values-sk/translations.xml b/features/logout/impl/src/main/res/values-sk/translations.xml
index 39301437fb..4fe07b2fa7 100644
--- a/features/logout/impl/src/main/res/values-sk/translations.xml
+++ b/features/logout/impl/src/main/res/values-sk/translations.xml
@@ -1,16 +1,16 @@
- "Ste si istí, že sa chcete odhlásiť?"
- "Odhlásiť sa"
- "Odhlásiť sa"
- "Prebieha odhlasovanie…"
+ "Naozaj chcete odstrániť toto zariadenie?"
+ "Odstrániť toto zariadenie"
+ "Odstrániť toto zariadenie"
+ "Odoberanie zariadenia…""Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam.""Vypli ste zálohovanie""Keď ste sa odpojili od internetu, vaše kľúče sa ešte stále zálohovali. Pripojte sa znova k internetu, aby sa vaše kľúče mohli zálohovať pred odhlásením.""Vaše kľúče sa ešte stále zálohujú""Pred odhlásením počkajte, kým sa to dokončí.""Vaše kľúče sa ešte stále zálohujú"
- "Odhlásiť sa"
+ "Odstrániť toto zariadenie""Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam.""Obnovenie nie je nastavené""Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."
diff --git a/features/logout/impl/src/main/res/values-uk/translations.xml b/features/logout/impl/src/main/res/values-uk/translations.xml
index 7e23189dc6..f012603533 100644
--- a/features/logout/impl/src/main/res/values-uk/translations.xml
+++ b/features/logout/impl/src/main/res/values-uk/translations.xml
@@ -1,9 +1,9 @@
- "Ви впевнені, що бажаєте вийти?"
+ "Ви впевнені, що хочете видалити цей пристрій?""Вийти""Вийти"
- "Вихід…"
+ "Видалення пристрою…""Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень.""Ви вимкнули резервне копіювання""Коли ви вийшли з мережі, резервна копія ваших ключів все ще створювалася. Повторно під\'єднайтеся, щоб зберегти резервну копію ключів перед виходом."
diff --git a/features/logout/impl/src/main/res/values-uz/translations.xml b/features/logout/impl/src/main/res/values-uz/translations.xml
index 4d03d9cfa3..a6b46bd5b5 100644
--- a/features/logout/impl/src/main/res/values-uz/translations.xml
+++ b/features/logout/impl/src/main/res/values-uz/translations.xml
@@ -1,17 +1,18 @@
"Haqiqatan ham tizimdan chiqmoqchimisiz?"
- "Tizimdan chiqish"
- "Tizimdan chiqish"
+ "Bu qurilmani olib tashlash"
+ "Bu qurilmani olib tashlash""Chiqish…"
- "Siz oxirgi sessiyangizdan chiqmoqdasiz. Agar hozir chiqib ketsangiz, shifrlangan xabarlaringizga kira olmaysiz."
- "Siz zaxira nusxasini oʻchirdingiz"
- "Siz oflayn bo‘lganingizda ham kalitlaringiz zaxiralanish jarayonida edi. Tizimdan chiqishdan oldin kalitlaringizning to‘liq zaxiralanishini ta’minlash uchun qayta ulanishingiz zarur."
+ "Bu sizning yagona qurilmangiz. Agar uni olib tashlasangiz, keyingi safar hisobingizga kirganingizda raqamli shaxsingizni tasdiqlash va shifrlangan chatlaringizni tiklash uchun zaxira kaliti kerak bo‘ladi."
+ "Shifrlangan chatlarga ruxsat yopiladi"
+ "Oflaynga chiqqaningizda kalitlaringiz hali ham zaxiralanayotgan edi. Bu qurilmani olib tashlashdan oldin kalitlaringiz zaxiralanishi uchun qayta ulaning.""Kalitlaringiz hamon zaxiralanmoqda"
- "Tizimdan chiqishdan oldin bu jarayon tugashini kuting."
+ "Bu qurilmani olib tashlashdan oldin uning tugashini kuting.""Kalitlaringiz hamon zaxiralanmoqda"
- "Tizimdan chiqish"
- "Siz oxirgi sessiyangizdan chiqmoqdasiz. Agar hozir chiqib ketsangiz, shifrlangan xabarlaringizga kira olmaysiz."
+ "Bu qurilmani olib tashlash"
+ "Bu sizning yagona qurilmangiz. Agar uni o‘chirsangiz, keyingi safar tizimga kirganingizda raqamli shaxsingizni tasdiqlash va shifrlangan chatlaringizni tiklash uchun tiklash kaliti kerak bo‘ladi.""Qayta tiklash sozlanmagan"
- "Siz oxirgi sessiyangizdan chiqmoqdasiz. Agar hozir chiqib ketsangiz, shifrlangan xabarlaringizga kira olmay qolishingiz mumkin."
+ "Bu sizning yagona qurilmangiz. Agar uni olib tashlasangiz, keyingi safar hisobingizga kirganingizda raqamli shaxsingizni tasdiqlash va shifrlangan chatlaringizni tiklash uchun zaxira kaliti kerak bo‘ladi."
+ "Bu qurilmani olib tashlashdan oldin zaxira kalitiga ruxsatingiz borligini tekshiring"
diff --git a/features/logout/impl/src/main/res/values-zh-rTW/translations.xml b/features/logout/impl/src/main/res/values-zh-rTW/translations.xml
index 12d5bc20a6..e2b71b61df 100644
--- a/features/logout/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/logout/impl/src/main/res/values-zh-rTW/translations.xml
@@ -1,17 +1,18 @@
- "您確定要登出嗎?"
- "登出"
- "登出"
- "正在登出…"
- "您將要登出上一次作業階段。若您現在登出,將會失去對加密訊息的存取權。"
- "您已關閉備份"
- "當您離線時,您的金鑰仍在備份中。請重新連線才能在您登出前備份金鑰。"
+ "您確定要移除此裝置嗎?"
+ "移除此裝置"
+ "移除此裝置"
+ "正在移除裝置……"
+ "這是您唯一的裝置。若您移除它,下次登入時您將需要還原金鑰來確認您的數位身份並還原您的加密聊天。"
+ "您即將失去對您加密聊天的存取權"
+ "當您離線時,您的金鑰仍在備份中。請重新連線才能在您移除此裝置前備份金鑰。""您的金鑰仍在備份中"
- "請等待此動作完成後再登出。"
+ "請等待此動作完成後再移除此裝置。""您的金鑰仍在備份中"
- "登出"
- "您將要登出上一次作業階段。若您現在登出,將會失去對加密訊息的存取權。"
- "未設定復原金鑰"
- "您將要登出上一次作業階段。若您現在登出,將會失去對加密訊息的存取權。"
+ "移除此裝置"
+ "這是您唯一的裝置。若您移除它,下此登入時將需要還原金鑰來驗證您的數位身份並還原您的加密聊天。"
+ "您即將失去對您的加密聊天的存取權"
+ "這是您唯一的裝置。若您移除它,下此登入時將需要還原金鑰來驗證您的數位身份並還原您的加密聊天。"
+ "在移除此裝置前,請確保您可存取您的還原金鑰"
diff --git a/features/logout/impl/src/main/res/values-zh/translations.xml b/features/logout/impl/src/main/res/values-zh/translations.xml
index d438c64ff3..f18904d03b 100644
--- a/features/logout/impl/src/main/res/values-zh/translations.xml
+++ b/features/logout/impl/src/main/res/values-zh/translations.xml
@@ -1,18 +1,18 @@
- "您确定要删除此设备吗?"
- "删除此设备"
- "删除此设备"
- "正在删除设备……"
- "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。"
- "您已关闭备份"
- "当你离线时,密钥仍在备份中。重新连接以便在登出之前备份密钥。"
- "您的密钥仍在备份中"
- "请等待此操作完成后再登出。"
- "您的密钥仍在备份中"
- "删除此设备"
- "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。"
- "未设置恢复"
- "即将登出最后一个会话。如果现在登出,将无法访问加密的消息。"
- "您保存了恢复密钥吗?"
+ "你确定要移除此设备?"
+ "移除此设备"
+ "移除此设备"
+ "正在移除设备…"
+ "这是你的唯一设备。一旦移除,下次登录时你需要使用恢复密钥验证数字身份并恢复加密聊天。"
+ "你即将失去加密聊天的访问权"
+ "当你离线时,密钥仍在备份。重新连接以便在移除设备之前备份密钥。"
+ "你的密钥仍在备份中"
+ "请等待此操作完成再移除此设备。"
+ "你的密钥仍在备份中"
+ "移除此设备"
+ "这是你的唯一设备。一旦移除,下次登录时你需要使用恢复密钥验证数字身份并恢复加密聊天。"
+ "你即将失去加密聊天的访问权"
+ "这是你的唯一设备。一旦移除,下次登录时你需要使用恢复密钥验证数字身份并恢复加密聊天。"
+ "确保你移除此设备前拥有恢复密钥"
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt
index 84ca038d7b..a42fd891d4 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.testtags.TestTags
@@ -21,97 +24,93 @@ 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.pressTag
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LogoutViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on logout sends a LogoutEvents`() {
+ fun `clicking on logout sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLogoutView(
+ setLogoutView(
aLogoutState(
eventSink = eventsRecorder
),
)
- rule.clickOn(CommonStrings.action_signout)
+ clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(LogoutEvents.Logout(false))
}
@Test
- fun `confirming logout sends a LogoutEvents`() {
+ fun `confirming logout sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLogoutView(
+ setLogoutView(
aLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder
),
)
- rule.pressTag(TestTags.dialogPositive.value)
+ pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(LogoutEvents.Logout(false))
}
@Test
- fun `clicking on back invoke back callback`() {
+ fun `clicking on back invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLogoutView(
+ setLogoutView(
aLogoutState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
- rule.pressBack()
+ pressBack()
}
}
@Test
- fun `clicking on confirm after error sends a LogoutEvents`() {
+ fun `clicking on confirm after error sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLogoutView(
+ setLogoutView(
aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder
),
)
- rule.clickOn(CommonStrings.action_signout_anyway)
+ clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(LogoutEvents.Logout(true))
}
@Test
- fun `clicking on cancel after error sends a LogoutEvents`() {
+ fun `clicking on cancel after error sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setLogoutView(
+ setLogoutView(
aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder
),
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(LogoutEvents.CloseDialogs)
}
@Test
- fun `last session setting button invoke onChangeRecoveryKeyClicked`() {
+ fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder(expectEvents = false)
ensureCalledOnce { callback ->
- rule.setLogoutView(
+ setLogoutView(
aLogoutState(
isLastDevice = true,
eventSink = eventsRecorder
),
onChangeRecoveryKeyClick = callback,
)
- rule.clickOn(CommonStrings.common_settings)
+ clickOn(CommonStrings.common_settings)
}
}
}
-private fun AndroidComposeTestRule.setLogoutView(
+private fun AndroidComposeUiTest.setLogoutView(
state: LogoutState,
onChangeRecoveryKeyClick: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled(),
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt
index 8eae534740..99860259c4 100644
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutViewTest.kt
@@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details.
*/
+@file:OptIn(ExperimentalTestApi::class)
+
package io.element.android.features.logout.impl.direct
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState
@@ -21,83 +24,79 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey
import org.junit.Ignore
-import org.junit.Rule
import org.junit.Test
-import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DefaultDirectLogoutViewTest {
- @get:Rule val rule = createAndroidComposeRule()
-
@Test
- fun `clicking on confirm logout sends expected Event`() {
+ fun `clicking on confirm logout sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDefaultDirectLogoutView(
+ setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
)
)
- rule.clickOn(CommonStrings.action_signout)
+ clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(false))
}
@Test
- fun `clicking on cancel logout sends expected Event`() {
+ fun `clicking on cancel logout sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDefaultDirectLogoutView(
+ setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
)
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
}
@Ignore("Pressing back key should dismiss the dialog, and so generate the expected event, but it's not the case.")
@Test
- fun `clicking on back invoke back callback`() {
+ fun `clicking on back invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDefaultDirectLogoutView(
+ setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder,
)
)
- rule.pressBackKey()
+ pressBackKey()
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
}
@Test
- fun `clicking on confirm after error sends expected Event`() {
+ fun `clicking on confirm after error sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDefaultDirectLogoutView(
+ setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
)
)
- rule.clickOn(CommonStrings.action_signout_anyway)
+ clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(true))
}
@Test
- fun `clicking on cancel after error sends expected Event`() {
+ fun `clicking on cancel after error sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder()
- rule.setDefaultDirectLogoutView(
+ setDefaultDirectLogoutView(
state = aDirectLogoutState(
logoutAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
)
)
- rule.clickOn(CommonStrings.action_cancel)
+ clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
}
}
-private fun AndroidComposeTestRule.setDefaultDirectLogoutView(
+private fun AndroidComposeUiTest.setDefaultDirectLogoutView(
state: DirectLogoutState,
) {
setContent {
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index 6ff7f7e322..2661f7e330 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -70,6 +70,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
+ implementation(libs.androidx.exifinterface)
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/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt
index bef8ca84d6..4d621e417f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt
@@ -18,6 +18,8 @@ sealed interface MessagesEvent {
data class ToggleReaction(val emoji: String, val eventOrTransactionId: EventOrTransactionId) : MessagesEvent
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvent
data class OnUserClicked(val user: MatrixUser) : MessagesEvent
+ data object StopLiveLocationShare : MessagesEvent
+ data object ShowLiveLocationShare : MessagesEvent
data object MarkAsFullyReadAndExit : MessagesEvent
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 646a19895a..d20dc4b38e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -24,7 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode
-import io.element.android.features.call.api.CallType
+import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
@@ -143,6 +143,7 @@ class MessagesFlowNode(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
+ val canUseOverlay: Boolean,
) : NavTarget
@Parcelize
@@ -227,10 +228,11 @@ class MessagesFlowNode(
callback.navigateToRoomDetails()
}
- override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
+ override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
+ canUseOverlay = canUseOverlay,
)
}
@@ -276,14 +278,18 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Live, eventId))
}
+ override fun navigateToCurrentLiveLocation() {
+ backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
+ }
+
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
- val callType = CallType.RoomCall(
+ val callData = CallData(
sessionId = sessionId,
roomId = roomId,
- isAudioCall = isAudioCall
+ isAudioCall = isAudioCall,
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
- elementCallEntryPoint.startCall(callType)
+ elementCallEntryPoint.startCall(callData)
}
override fun navigateToPinnedMessagesList() {
@@ -320,7 +326,11 @@ class MessagesFlowNode(
)
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
- overlay.hide()
+ if (navTarget.canUseOverlay) {
+ overlay.hide()
+ } else {
+ backstack.pop()
+ }
}
override fun viewInTimeline(eventId: EventId) {
@@ -414,10 +424,11 @@ class MessagesFlowNode(
}
NavTarget.PinnedMessagesList -> {
val callback = object : PinnedMessagesListNode.Callback {
- override fun handleEventClick(event: TimelineItem.Event) {
+ override fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) {
processEventClick(
timelineMode = Timeline.Mode.PinnedEvents,
event = event,
+ canUseOverlay = canUseOverlay,
)
}
@@ -456,10 +467,11 @@ class MessagesFlowNode(
focusedEventId = navTarget.focusedEventId,
)
val callback = object : ThreadedMessagesNode.Callback {
- override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean {
+ override fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean {
return processEventClick(
timelineMode = timelineMode,
event = event,
+ canUseOverlay = canUseOverlay,
)
}
@@ -505,14 +517,18 @@ class MessagesFlowNode(
backstack.push(NavTarget.EditPoll(Timeline.Mode.Thread(navTarget.threadRootId), eventId))
}
+ override fun navigateToCurrentLiveLocation() {
+ backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
+ }
+
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
- val callType = CallType.RoomCall(
+ val callData = CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = isAudioCall
)
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
- elementCallEntryPoint.startCall(callType)
+ elementCallEntryPoint.startCall(callData)
}
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {
@@ -547,6 +563,7 @@ class MessagesFlowNode(
private fun processEventClick(
timelineMode: Timeline.Mode,
event: TimelineItem.Event,
+ canUseOverlay: Boolean,
): Boolean {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
@@ -556,6 +573,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
+ canUseOverlay = canUseOverlay,
)
}
is TimelineItemVideoContent -> {
@@ -565,6 +583,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
+ canUseOverlay = canUseOverlay,
)
}
is TimelineItemFileContent -> {
@@ -574,6 +593,7 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
+ canUseOverlay = canUseOverlay,
)
}
is TimelineItemAudioContent -> {
@@ -583,26 +603,32 @@ class MessagesFlowNode(
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
+ canUseOverlay = canUseOverlay,
)
}
is TimelineItemLocationContent -> {
- val mode = ShowLocationMode.Static(
- location = event.content.location,
- senderName = event.safeSenderName,
- senderId = event.senderId,
- senderAvatarUrl = event.senderAvatar.url,
- timestamp = event.sentTimeMillis,
- assetType = event.content.assetType,
- )
- NavTarget.LocationViewer(
- mode = mode
- ).takeIf { locationService.isServiceAvailable() }
+ val mode = when (event.content.mode) {
+ is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
+ is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
+ location = event.content.mode.location,
+ senderName = event.safeSenderName,
+ senderId = event.senderId,
+ senderAvatarUrl = event.senderAvatar.url,
+ timestamp = event.sentTimeMillis,
+ assetType = event.content.assetType,
+ )
+ }
+ NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
}
else -> null
}
return when (navTarget) {
is NavTarget.MediaViewer -> {
- overlay.show(navTarget)
+ if (canUseOverlay) {
+ overlay.show(navTarget)
+ } else {
+ backstack.push(navTarget)
+ }
true
}
is NavTarget.LocationViewer -> {
@@ -619,6 +645,7 @@ class MessagesFlowNode(
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
+ canUseOverlay: Boolean,
): NavTarget {
return NavTarget.MediaViewer(
mode = mode,
@@ -627,6 +654,7 @@ class MessagesFlowNode(
filename = content.filename,
fileSize = content.fileSize,
caption = content.caption,
+ formattedCaption = content.formattedCaption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
@@ -646,6 +674,7 @@ class MessagesFlowNode(
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
+ canUseOverlay = canUseOverlay,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
index e475f579c3..6113b68aab 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt
@@ -26,5 +26,6 @@ interface MessagesNavigator {
fun navigateToMember(userId: UserId)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToDeveloperSettings()
+ fun navigateToCurrentLiveLocation()
fun close()
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index a2cf4a3da0..a9ce2f5ba1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -68,6 +68,8 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.strings.CommonStrings
+import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
+import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
@@ -115,7 +117,7 @@ class MessagesNode(
)
interface Callback : Plugin {
- fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event): Boolean
+ fun handleEventClick(timelineMode: Timeline.Mode, event: TimelineItem.Event, canUseOverlay: Boolean): Boolean
fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?)
fun navigateToRoomMemberDetails(userId: UserId)
fun handlePermalinkClick(data: PermalinkData)
@@ -125,6 +127,7 @@ class MessagesNode(
fun navigateToSendLocation()
fun navigateToCreatePoll()
fun navigateToEditPoll(eventId: EventId)
+ fun navigateToCurrentLiveLocation()
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
fun navigateToRoomDetails()
@@ -237,6 +240,10 @@ class MessagesNode(
callback.navigateToDeveloperSettings()
}
+ override fun navigateToCurrentLiveLocation() {
+ callback.navigateToCurrentLiveLocation()
+ }
+
private fun displaySameRoomToast() {
context.toast(CommonStrings.screen_room_permalink_same_room_android)
}
@@ -247,6 +254,7 @@ class MessagesNode(
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
+ val canUseOverlay = !isTalkbackActive() && !hasExternalKeyboard()
CompositionLocalProvider(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
@@ -268,11 +276,11 @@ class MessagesNode(
onRoomDetailsClick = callback::navigateToRoomDetails,
onEventContentClick = { isLive, event ->
if (isLive) {
- callback.handleEventClick(timelineController.mainTimelineMode(), event)
+ callback.handleEventClick(timelineController.mainTimelineMode(), event, canUseOverlay)
} else {
val detachedTimelineMode = timelineController.detachedTimelineMode()
if (detachedTimelineMode != null) {
- callback.handleEventClick(detachedTimelineMode, event)
+ callback.handleEventClick(detachedTimelineMode, event, canUseOverlay)
} else {
false
}
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 f115dd2799..6754d703a3 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
@@ -27,6 +27,8 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.PinUnpinAction
import io.element.android.appconfig.MessageComposerConfig
+import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
+import io.element.android.features.location.api.live.isCurrentlySharing
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.MessagesState.Threads
import io.element.android.features.messages.impl.actionlist.ActionListState
@@ -77,8 +79,8 @@ 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.RoomMembersState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
-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.timeline.Timeline
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
@@ -126,6 +128,7 @@ class MessagesPresenter(
private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
private val markAsFullyRead: MarkAsFullyRead,
+ private val liveLocationShareManager: ActiveLiveLocationShareManager,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter {
@AssistedFactory
@@ -172,6 +175,7 @@ class MessagesPresenter(
}
val canOpenThreadList by featureFlagService.isFeatureEnabledFlow(FeatureFlags.RoomThreadList).collectAsState(initial = false)
+ val isCurrentlySharingLiveLocationInRoom by remember { liveLocationShareManager.isCurrentlySharing(room.roomId) }.collectAsState()
val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms ->
perms.userEventPermissions()
@@ -217,12 +221,10 @@ class MessagesPresenter(
val dmRoomMember by room.getDirectRoomMember(membersState)
val roomMemberIdentityStateChanges = identityChangeState.roomMemberIdentityStateChanges
- val isKeyShareOnInviteEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false)
// The top bar should show a "history" icon if:
- // * History sharing is enabled,
// * The room is encrypted, and:
// * The room's history_visibility allows future users to see content.
- val topBarSharedHistoryIcon = if (isKeyShareOnInviteEnabled) roomInfo.sharedHistoryIcon() else SharedHistoryIcon.NONE
+ val topBarSharedHistoryIcon = roomInfo.sharedHistoryIcon()
LifecycleResumeEffect(dmRoomMember, roomInfo.isEncrypted) {
if (roomInfo.isEncrypted == true) {
@@ -262,6 +264,18 @@ class MessagesPresenter(
is MessagesEvent.OnUserClicked -> {
roomMemberModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.user))
}
+ MessagesEvent.StopLiveLocationShare -> {
+ localCoroutineScope.launch {
+ liveLocationShareManager.stopShare(room.roomId)
+ .onFailure {
+ Timber.e(it, "Failed to stop live location share for roomId=${room.roomId}")
+ snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
+ }
+ }
+ }
+ MessagesEvent.ShowLiveLocationShare -> {
+ navigator.navigateToCurrentLiveLocation()
+ }
is MessagesEvent.MarkAsFullyReadAndExit -> if (!markingAsReadAndExiting.getAndSet(true)) {
coroutineScope.launch {
val latestEventId = room.liveTimeline.getLatestEventId().getOrElse {
@@ -274,6 +288,8 @@ class MessagesPresenter(
}
}
navigator.close()
+ }.invokeOnCompletion {
+ markingAsReadAndExiting.set(false)
}
}
}
@@ -311,6 +327,7 @@ class MessagesPresenter(
// TODO calculate this properly based on the thread list and the read state of each thread
hasUnreadThreads = false,
),
+ showLiveLocationShareBanner = isCurrentlySharingLiveLocationInRoom && timelineState.timelineMode !is Timeline.Mode.Thread,
eventSink = ::handleEvent,
)
}
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 862f30832b..a16485c6f7 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
@@ -58,6 +58,7 @@ data class MessagesState(
val topBarSharedHistoryIcon: SharedHistoryIcon,
val successorRoom: SuccessorRoom?,
val threads: Threads,
+ val showLiveLocationShareBanner: Boolean,
val eventSink: (MessagesEvent) -> Unit
) {
val isTombstoned = successorRoom != null
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 16021df3e9..6389089e07 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
@@ -44,6 +44,7 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.ROOM_NAME
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.encryption.identity.IdentityState
@@ -79,6 +80,7 @@ open class MessagesStateProvider : PreviewParameterProvider {
currentPinnedMessageIndex = 0,
),
),
+ aMessagesState(isCurrentlySharingLiveLocationInRoom = true),
aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)),
aMessagesState(
timelineState = aTimelineState(
@@ -94,8 +96,8 @@ open class MessagesStateProvider : PreviewParameterProvider