diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 95f322a2e4..9ed1919e0e 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -52,6 +52,7 @@ Uncomment this markdown table below and edit the last line `|||`:
+- [ ] I am aware of the [etiquette](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#etiquette).
- This PR was made with the help of AI:
- [ ] Yes. In this case, please request a review by Copilot.
- [ ] No.
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 dd5524c7e3..99b5ce9ef0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -44,19 +44,19 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs
@@ -80,7 +80,7 @@ jobs:
path: |
app/build/outputs/apk/gplay/debug/*-universal-debug.apk
app/build/outputs/apk/fdroid/debug/*-universal-debug.apk
- - uses: rnkdsh/action-upload-diawi@4e1421305be7cfc510d05f47850262eeaf345108 # v1.5.12
+ - uses: rnkdsh/action-upload-diawi@76778fcd1f9f589efaaa62f63d5f03430bbb1306 # v1.5.13
id: diawi
# Do not fail the whole build if Diawi upload fails
continue-on-error: true
diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml
index 956655b262..c176864b46 100644
--- a/.github/workflows/build_enterprise.yml
+++ b/.github/workflows/build_enterprise.yml
@@ -43,7 +43,7 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -56,12 +56,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index 4bb51d05b5..a8bf7b623a 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -11,7 +11,7 @@ jobs:
# Skip in forks, it doesn't work even with the fallback token
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
@@ -24,7 +24,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
- uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5
+ uses: danger/danger-js@58355dc2b274a87699c8b7deac01baba16d6f061 # 13.0.8
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:
diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml
index 55a300dd88..4b0d28b00b 100644
--- a/.github/workflows/generate_github_pages.yml
+++ b/.github/workflows/generate_github_pages.yml
@@ -18,12 +18,12 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -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/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml
index 66078b7b4b..d29cca5400 100644
--- a/.github/workflows/gradle-wrapper-update.yml
+++ b/.github/workflows/gradle-wrapper-update.yml
@@ -13,10 +13,10 @@ jobs:
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml
index 9af5c6bc1d..558d1b0a0c 100644
--- a/.github/workflows/maestro-local.yml
+++ b/.github/workflows/maestro-local.yml
@@ -38,19 +38,19 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }}
persist-credentials: false
- - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
name: Use JDK 21
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
@@ -78,7 +78,7 @@ jobs:
concurrency:
group: maestro-test
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with:
# Ensure we are building the branch and not the branch after being merged on develop
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 31a8806a85..f82d904a89 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -32,11 +32,11 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml
index 371c11b3ff..54f77ea35d 100644
--- a/.github/workflows/nightlyReports.yml
+++ b/.github/workflows/nightlyReports.yml
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: false
@@ -76,16 +76,16 @@ jobs:
name: Dependency analysis
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index c8c0a98dea..e24847280e 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -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@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
id: teams
with:
username: ${{ github.event.pull_request.user.login }}
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index ceaa86016a..c2bf89a07f 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -33,7 +33,7 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
@@ -51,7 +51,7 @@ jobs:
name: Search for invalid screenshot files
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python 3.12
@@ -65,16 +65,16 @@ jobs:
name: Search for invalid dependencies
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
@@ -93,7 +93,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -108,12 +108,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests
@@ -134,7 +134,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -149,12 +149,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests
@@ -168,7 +168,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -183,12 +183,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug
@@ -213,7 +213,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -228,12 +228,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
@@ -254,7 +254,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -269,12 +269,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
@@ -295,7 +295,7 @@ jobs:
group: ${{ github.ref == 'refs/heads/main' && format('check-docs-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-docs-develop-{0}', github.sha) || format('check-docs-{0}', github.ref) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -319,7 +319,7 @@ jobs:
name: Check shell scripts
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run shellcheck
@@ -333,10 +333,10 @@ jobs:
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
+ - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
upload_reports:
name: Project Check Suite
@@ -344,7 +344,7 @@ jobs:
needs: [konsist, lint, ktlint, detekt]
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
@@ -360,7 +360,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
- uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5
+ uses: danger/danger-js@58355dc2b274a87699c8b7deac01baba16d6f061 # 13.0.8
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:
diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml
index 0f4c8ee581..7465a6d6f8 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'
@@ -53,13 +54,13 @@ jobs:
with:
persist-credentials: false
- name: ☕️ Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
# Add gradle cache, this should speed up the process
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index eece5ab0d4..fee9efb518 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,16 +34,16 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Create app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -71,7 +71,7 @@ jobs:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Add SSH private keys for submodule repositories
@@ -82,12 +82,12 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Create Enterprise app bundle
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -122,16 +122,16 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml
index 40cf9d0058..4cb6bddf39 100644
--- a/.github/workflows/sonar.yml
+++ b/.github/workflows/sonar.yml
@@ -38,19 +38,19 @@ jobs:
docker-images: true
swap-storage: false
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures
diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml
index 1958e80083..622fecc403 100644
--- a/.github/workflows/stale-issues.yml
+++ b/.github/workflows/stale-issues.yml
@@ -12,7 +12,7 @@ jobs:
permissions:
issues: write
steps:
- - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
+ - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
only-labels: "X-Needs-Info"
days-before-issue-stale: 30
diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml
index 6de0f3b0df..8d08702e4a 100644
--- a/.github/workflows/sync-localazy.yml
+++ b/.github/workflows/sync-localazy.yml
@@ -13,16 +13,16 @@ jobs:
# Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml
index 9f7a67cc22..f84b1596cb 100644
--- a/.github/workflows/sync-sas-strings.yml
+++ b/.github/workflows/sync-sas-strings.yml
@@ -14,7 +14,7 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
# No concurrency required, runs every time on a schedule.
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python 3.12
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 87551c4360..5466bc80af 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -63,12 +63,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: ☕️ Use JDK 21
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21'
- name: Configure gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
@@ -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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
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..089816d5c8 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/AGENTS.md b/AGENTS.md
index 454c347a9f..d206be98c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,11 +44,14 @@ PRs must meet these rules.
## Pull Request Guidelines
-- Use sentence-style commit/PR messages (no conventional commits).
-- Apply exactly **one** `PR-` label for changelog categorization.
-- PR title = changelog entry — make it descriptive; no "Fixes #…" prefixes.
-- Include screenshots or screen recordings for any UI changes.
-- Keep PRs focused; split changes over 1000 lines.
+- Sentence-style titles (no conventional commits).
+- Exactly one `pr-` label (see `.github/release.yml`).
+- Title = changelog entry — descriptive, no "Fixes #…".
+- Leave description template for the developer. Redirect them to the [contributing etiquette](CONTRIBUTING.md#etiquette).
+- Screenshots/videos for visual changes.
+- 500 additions max — split large changes.
+- Commits need a title and description; no tiny or massive commits.
+- No history rewrites.
---
diff --git a/CHANGES.md b/CHANGES.md
index df109d34a6..f08a3f9d13 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,365 @@
+Changes in Element X v26.06.4
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Add well-known parsing and extension seam for custom recovery passphrase by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/6944
+### 🙌 Improvements
+* Add ability to forward a message to several rooms. by @bmarty in https://github.com/element-hq/element-x-android/pull/7013
+### 🐛 Bugfixes
+* `screen_custom_recovery_key_input_notice` is now a plurals. by @bmarty in https://github.com/element-hq/element-x-android/pull/7045
+* Replace `ContentResolver.delete` usage with a backwards-compatible version by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7053
+### Dependency upgrades
+* Update kotlin to v0.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7036
+* Update gradle/actions action to v6.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7037
+* Update jna monorepo to v5.19.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7026
+* Update coil to v3.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7043
+* Update metro to v1.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7048
+* Update dependency io.sentry:sentry-android to v8.43.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7047
+* Update dependencyAnalysis to v3.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7050
+* Compile with sdk37 and target API 37 by @bmarty in https://github.com/element-hq/element-x-android/pull/7041
+### Others
+* call: Remove deprecated web<->EX api calls, use the new ones by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/6628
+* Fix deprecation warning by @bmarty in https://github.com/element-hq/element-x-android/pull/7042
+* Let Divider use our theme color. by @bmarty in https://github.com/element-hq/element-x-android/pull/7054
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.06.3...v26.06.4
+
+Changes in Element X v26.06.3
+=============================
+
+
+
+## What's Changed
+### ✨ Features
+* Click on avatar in moderation bottom sheet opens avatar preview by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6991
+### 🙌 Improvements
+* Change: hide UserLocationPuck when sharing from current device by @ganfra in https://github.com/element-hq/element-x-android/pull/6955
+* Replace "Edit" button with Crop icon. by @bmarty in https://github.com/element-hq/element-x-android/pull/6953
+* change(location): ensure permissions are always requested at least once by @ganfra in https://github.com/element-hq/element-x-android/pull/6996
+* Refactor: replace client-side event filtering for public rooms with SDK by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7025
+### 🐛 Bugfixes
+* Ensure the video preview can be played several times. by @bmarty in https://github.com/element-hq/element-x-android/pull/6964
+* Fix "Sent" checkmark disappearing when the message is read and "Share presence" is disabled by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6985
+* Make `BaseRoom.getDirectRoomMember` change when `roomInfo.isDm` changes by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6989
+* Fix invites not being sent during the room creation by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7018
+* Fix sharing media inside the app by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7016
+* Fix ANRs when calculating DB sizes for Sentry by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7017
+* Fix thread list not loading older threads by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7034
+### 🗣 Translations
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6986
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7021
+### 🧱 Build
+* Try fixing flaky screenshots for location timeline items by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6954
+* `ScanQrCodeViewTest`: try fixing broken test in CI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7027
+### 🚧 In development 🚧
+* Ensure that the application rotate the QrCode if one day the SDK emits the `.Expired` error. by @bmarty in https://github.com/element-hq/element-x-android/pull/6993
+### Dependency upgrades
+* Update dependency androidx.javascriptengine:javascriptengine to v1.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6785
+* Update dependency com.google.firebase:firebase-bom to v34.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6967
+* Update dependency com.github.matrix-org:matrix-analytics-events to v0.36.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6963
+* Update dependencyAnalysis to v3.14.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6892
+* Update dependency io.element.android:element-call-embedded to v0.20.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6988
+* Update dependency io.mockk:mockk to v1.14.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6977
+* Update zizmorcore/zizmor-action action to v0.5.6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6845
+* deps(codecov) : bumps to v7.0.0 to unblock CI by @ganfra in https://github.com/element-hq/element-x-android/pull/7002
+* Update wysiwyg to v2.42.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7009
+* Update actions/checkout action to v6.0.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6997
+* fix(deps): update android.gradle.plugin to v9 (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6021
+* Update dependencyAnalysis to v3.14.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6995
+* Update dependency io.sentry:sentry-android to v8.43.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6895
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7014
+* Update dependency org.maplibre.gl:android-sdk to v13.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7022
+* Update dependency com.google.firebase:firebase-bom to v34.14.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7029
+* Update okhttp monorepo to v5.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7031
+### Others
+* Fix some string mappings for link new device errors by @hughns in https://github.com/element-hq/element-x-android/pull/6675
+* Fallback to the default push gateway on error by @p1gp1g in https://github.com/element-hq/element-x-android/pull/5742
+* Fix some string mappings for link new device errors by @bmarty in https://github.com/element-hq/element-x-android/pull/6958
+* Add `/myroomnick` slash command to change your display name in the current room by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6865
+* Update release notes for `v26.05.1` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6990
+* Fix message drafts not being saved in Threads by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6984
+* build-rust-sdk: allow skipping git update for kotlin components by @richvdh in https://github.com/element-hq/element-x-android/pull/7012
+* Fix broken screenshot tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7038
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.06.2...v26.06.3
+
+Changes in Element X v26.06.2
+=============================
+
+Security fixes 🔐
+-----------------
+- Fixed an issue with OIDC callback URLs that could cause the app to crash [(GHSA-r4f9-46vw-v7g3)](https://github.com/element-hq/element-x-android/security/advisories/GHSA-r4f9-46vw-v7g3).
+
+Changes in Element X v26.06.1
+=============================
+
+The release of `v26.06.0` was cancelled because it contained some changes that would prevent some users with devices having no Vulkan support from being able to install the app. This release contains the same changes as `v26.06.0` but with the Vulkan requirement removed.
+
+## What's Changed
+### ✨ Features
+* Image edition before sending: crop and rotate. by @bmarty in https://github.com/element-hq/element-x-android/pull/6842
+* Use a raw key for the SDK stores for new sessions by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6835
+* Notification settings: pick custom sounds for messages and call ringtones by @bmarty in https://github.com/element-hq/element-x-android/pull/6897
+### 🙌 Improvements
+* Do not show membership/profile events in public rooms by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6360
+* Use `runBlocking` for the token refresh logic by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6863
+* Move empty day separator filtering to a timeline post-processor by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6866
+* Add better logs to track token update failures by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6859
+* Read map tiler custom style from matrix .well-known file by @bmarty in https://github.com/element-hq/element-x-android/pull/6886
+* change(permissions) : allow to change ShareLiveLocation permission by @ganfra in https://github.com/element-hq/element-x-android/pull/6890
+* Avoid SQLCipher key derivation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6774
+* change: replace the maplibre-compose UserLocationState to a simpler one by @ganfra in https://github.com/element-hq/element-x-android/pull/6913
+* Change : location provider by @ganfra in https://github.com/element-hq/element-x-android/pull/6935
+* Add flip actions to image edition by @bmarty in https://github.com/element-hq/element-x-android/pull/6949
+* Remove the `FloatingDateBadge` feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6950
+### 🐛 Bugfixes
+* Fix media viewer flickering by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6800
+* Fix 'Conversation label cannot be empty' error by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6823
+* Release proximity wakelock on Element Call when call ends by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6825
+* Hide edit pencil from accessibility by @bmarty in https://github.com/element-hq/element-x-android/pull/6826
+* Don't compress images sent through the Files attachment picker by @cizra in https://github.com/element-hq/element-x-android/pull/6755
+* [a11y] Improve accessibility of video and audio player by @bmarty in https://github.com/element-hq/element-x-android/pull/6830
+* [a11y] Improve accessibility of screen headers. by @bmarty in https://github.com/element-hq/element-x-android/pull/6827
+* Add mark as read / unread in room details by @bmarty in https://github.com/element-hq/element-x-android/pull/6818
+* Fix formatting inconsistencies in latest event summaries by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6855
+* Fix public read receipts being sent by mistake by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6838
+* Fix app having a pink top bar in the recent app list when PIN lock is setup by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6903
+* Create log messages from `WebView` just once by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6923
+* Use secondary color icon for leading icons by @bmarty in https://github.com/element-hq/element-x-android/pull/6926
+* Use reverse ordering for `FilterEmptyDayPostProcessor` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6927
+* Make Vulkan a *not required* feature by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6961
+### 🗣 Translations
+* Sync Strings - new translations to Catalan by @ElementBot in https://github.com/element-hq/element-x-android/pull/6856
+* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/6921
+### 🧱 Build
+* Remove DI-generated code from Kover reports by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6834
+* Disable cron jobs and private SSH key jobs in forks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6821
+* Revert "Disable cron jobs and private SSH key jobs in forks" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6905
+* Downgrade detekt compose rules by @bmarty in https://github.com/element-hq/element-x-android/pull/6846
+* Add Stefan's etiquette to the project. by @bmarty in https://github.com/element-hq/element-x-android/pull/6909
+### 🚧 In development 🚧
+* [Link new device] Rotate QrCode instead of showing an error by @bmarty in https://github.com/element-hq/element-x-android/pull/6817
+* Fix image resizing behavior. by @bmarty in https://github.com/element-hq/element-x-android/pull/6924
+### Dependency upgrades
+* Update dependency net.zetetic:sqlcipher-android to v4.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6819
+* Update dependencyAnalysis to v3.11.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6813
+* Update metro to v1.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6832
+* Update dependency io.element.android:element-call-embedded to v0.19.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6833
+* Update kotlin to v2.3.8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6822
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.20 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6831
+* Update media3 to v1.10.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6816
+* Update peaceiris/actions-gh-pages action to v4.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6820
+* Update dependencyAnalysis to v3.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6840
+* Update codecov/codecov-action action to v6.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6864
+* Update dependency io.element.android:element-call-embedded to v0.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6876
+* Update roborazzi to v1.62.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6858
+* Update dependency androidx.compose:compose-bom to v2026.05.01 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6888
+* Update roborazzi to v1.63.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6893
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.05.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6885
+* Update dependency org.maplibre.compose:maplibre-compose to v0.13.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6891
+* Update actions/stale action to v10.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6904
+* Update tspascoal/get-user-teams-membership action to v4.0.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6900
+* Update dependency org.maplibre.gl:android-sdk to v13.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6930
+* Update dependency org.unifiedpush.android:connector to v3.3.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6937
+* Update kotlin to v2.3.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6946
+* Update dependency com.posthog:posthog-android to v3.47.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6920
+* Import compound token from release 10.2.1 https://github.com/element-hq/compound-design-tokens/releases/tag/v10.2.1 by @bmarty in https://github.com/element-hq/element-x-android/pull/6942
+* Update dependency org.matrix.rustcomponents:sdk-android to v26.06.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6947
+* Update dependency com.github.matrix-org:matrix-analytics-events to v0.36.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/6939
+### Others
+* Make the avatar in the room member moderation bottom sheet clickable by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6705
+* Do not hide your own media for "Show media in timeline" by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6898
+* Report problem: add an optional TextField to provide a GitHub issue number. by @bmarty in https://github.com/element-hq/element-x-android/pull/6911
+* Fix read receipts not shown for calls by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6889
+* Reorder room detail items by @bmarty in https://github.com/element-hq/element-x-android/pull/6929
+* [Media viewer] File preview improvement by @bmarty in https://github.com/element-hq/element-x-android/pull/6933
+* [Media bottom sheet] UI iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/6938
+* Add unread count to the room unread indicator by @bxdxnn in https://github.com/element-hq/element-x-android/pull/6887
+
+
+**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.05.2...v26.06.1
+
+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
+=============================
+
+
+
+Security fixes 🔐
+-----------------
+
+This release contains some important security fixes:
+
+- Check the user ID in the `sender_device_keys` property of Olm-encrypted to-device events to prevent sender spoofing by homeserver owners. ([#6553](https://github.com/matrix-org/matrix-rust-sdk/pull/6553), High, [CVE-2026-45056](https://www.cve.org/CVERecord?id=CVE-2026-45056), [GHSA-wfq4-36m3-9g42](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-wfq4-36m3-9g42))
+- Reject invalid edits as candidates for timeline updates. ([#6454](https://github.com/matrix-org/matrix-rust-sdk/pull/6454), Moderate, [CVE-2026-45057](https://www.cve.org/CVERecord?id=CVE-2026-45057), [GHSA-h97m-27fx-42rx](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-h97m-27fx-42rx))
+
+## 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
=============================
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0126d89a9c..ec131aa65f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,7 +6,8 @@
* [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)
+ * [Etiquette](#etiquette)
+ * [Developer onboarding](#developer-onboarding)
* [Submitting the PRs](#submitting-the-prs)
* [Android Studio settings](#android-studio-settings)
* [Compilation](#compilation)
@@ -63,7 +64,27 @@ Once we know that you want to contribute and have confirmed that the new feature
Only once all of the above is met should you open a PR with your proposed changes.
-## Developer onboarding
+### Etiquette
+
+* As stated above all significant changes should be communicated through an issue before opening a PR
+* We are happy to receive contributions but features always require maintenance, so depending on the change we might not be willing to accept it
+* We are also fine with AI led contributions within reasonable bounds
+ * You are completely responsible for the quality of the PR
+ * If the PR doesn't show minimal effort (code does not compile, code does not work as expected, etc.) on your part it will be closed
+ * Please write the description yourself, we don't have the bandwidth to read LLM essays. The code needs to speak for itself.
+* We use git for version control and GitHub for reviews, so in order to make everybody's life easier please:
+ * Keep the existing pull request template
+ * Don't submit large PRs, especially if not previously talked about. Anything above 200 lines is large (excluding generated code e.g. tests, translations, mocks)
+ * Please don't open unfinished PRs and expect us to fill in the details
+ * If you would like our opinion/direction on unfinished code please link your branch or idea in the ticket
+ * Please limit the number of commits in a single PR. We are perfectly happy with splitting work across multiple sessions as long as they're logically independant and show promise of progress (ideally expressed through a ticket)
+ * Each and every commit should stand on its own, clearly explaining what it does and why
+* Once a PR goes into review please don't rewrite the history unless agreed so with the reviewer.
+ * Tweaks and fixes following review can be directly committed (to be interactively rebased later) or as fixups
+
+*The reviewer's response time will generally match yours. Switching contexts is very hard so please act accordingly. You are responsible for making the reviewers job enjoyable!*
+
+### Developer onboarding
For a detailed overview of the project, see [Developer Onboarding](./docs/_developer_onboarding.md).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a4ee1c8459..ecd5ed8e7b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -24,11 +24,11 @@ import extension.buildConfigFieldStr
import extension.locales
import extension.setupDependencyInjection
import extension.testCommonDependencies
+import org.sonarqube.gradle.SonarResolverTask
import java.util.Locale
plugins {
id("io.element.android-compose-application")
- alias(libs.plugins.kotlin.android)
// When using precompiled plugins, we need to apply the firebase plugin like this
id(libs.plugins.firebaseAppDistribution.get().pluginId)
id("kotlin-parcelize")
@@ -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")
@@ -189,6 +189,7 @@ android {
buildFeatures {
buildConfig = true
+ resValues = true
}
flavorDimensions += "store"
productFlavors {
@@ -245,6 +246,11 @@ androidComponents {
configureLicensesTasks(reportingExtension)
}
+// Configure the SonarQube plugin to wait for the resource generation tasks to complete before running the analysis.
+tasks.withType().configureEach {
+ dependsOn("generateGplayDebugResValues", "generateGplayDebugAndroidTestResValues")
+}
+
setupDependencyInjection()
dependencies {
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/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/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index af159a0b11..2e610fc0c7 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -11,7 +11,14 @@
- @color/splashscreen_bg_dark
- @drawable/transparent
- @style/Theme.ElementX
+ - @color/splashscreen_bg_dark
+ - @color/splashscreen_bg_dark
+ - @color/splashscreen_bg_dark
-
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 4190fd84f2..26cb0a56bd 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -10,8 +10,14 @@
- @color/splashscreen_bg_light
- @drawable/transparent
- @style/Theme.ElementX
+ - @color/splashscreen_bg_light
+ - @color/splashscreen_bg_light
+ - @color/splashscreen_bg_light
diff --git a/app/src/main/res/xml/file_providers.xml b/app/src/main/res/xml/file_providers.xml
index ac74590cba..330a2e0c3f 100644
--- a/app/src/main/res/xml/file_providers.xml
+++ b/app/src/main/res/xml/file_providers.xml
@@ -7,4 +7,5 @@
-->
+
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/intent/DefaultIntentProviderTest.kt b/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt
index 4598a74e51..be059ed2a8 100644
--- a/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt
+++ b/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt
@@ -24,14 +24,12 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.x.MainActivity
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
-@RunWith(RobolectricTestRunner::class)
-class DefaultIntentProviderTest {
+class DefaultIntentProviderTest : RobolectricTest() {
@Test
fun `test getViewRoomIntent with data`() {
val deepLinkCreator = lambdaRecorder { _, _, _, _ -> "deepLinkCreatorResult" }
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/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 70376ffcb9..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
@@ -151,6 +152,7 @@ class LoggedInFlowNode(
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
+ private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -211,6 +213,7 @@ class LoggedInFlowNode(
super.onBuilt()
lifecycleScope.launch {
sessionEnterpriseService.init()
+ activeLiveLocationShareManager.setup()
}
lifecycle.subscribe(
onCreate = {
@@ -219,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 }
@@ -380,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)
}
}
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/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/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
index 6dffda9e42..5ef3d8cd70 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt
@@ -45,15 +45,13 @@ import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMess
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-@RunWith(RobolectricTestRunner::class)
-class JoinedRoomLoadedFlowNodeTest {
+class JoinedRoomLoadedFlowNodeTest : RobolectricTest() {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
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..841f79678b 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,16 +26,14 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
-@RunWith(RobolectricTestRunner::class)
-class IntentResolverTest {
+class IntentResolverTest : RobolectricTest() {
@Test
fun `resolve launcher intent should return null`() {
val sut = createIntentResolver()
@@ -170,9 +168,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 +178,8 @@ class IntentResolverTest {
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
- ResolvedIntent.Oidc(
- oidcAction = OidcAction.GoBack()
+ ResolvedIntent.OAuth(
+ oAuthAction = OAuthAction.GoBack()
)
)
}
@@ -194,7 +192,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 +211,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 +228,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 +242,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 +258,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 +275,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 +290,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 +304,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 +312,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 902f446a6f..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
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 dfd32f0872..0e0df9dbc6 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -30,7 +30,7 @@ tasks.register("clean").configure {
delete(rootProject.layout.buildDirectory)
}
-private val ktLintVersion = the().versions.ktlint.get()
+private val catalog = the()
allprojects {
// Detekt
@@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
- detektPlugins("io.nlopez.compose.rules:detekt:0.5.7")
+ detektPlugins(catalog.detekt.compose.rules)
detektPlugins(project(":tests:detekt-rules"))
}
@@ -64,7 +64,7 @@ allprojects {
// See https://github.com/JLLeitschuh/ktlint-gradle#configuration
configure {
- version = ktLintVersion
+ version = catalog.versions.ktlint.get()
android = true
ignoreFailures = false
enableExperimentalRules = true
@@ -106,8 +106,6 @@ allprojects {
// Fix compilation warning for annotations
// See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details
freeCompilerArgs.add("-Xannotation-default-target=first-only")
- // Opt-in to context receivers
- freeCompilerArgs.add("-Xcontext-parameters")
}
}
}
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..efdf9d9a15 160000
--- a/enterprise
+++ b/enterprise
@@ -1 +1 @@
-Subproject commit cdde60c158ecd0987a3ba6fd79a4617551aff463
+Subproject commit efdf9d9a15a5b600c55f674d64794e7737ad906e
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/fastlane/metadata/android/en-US/changelogs/202606000.txt b/fastlane/metadata/android/en-US/changelogs/202606000.txt
new file mode 100644
index 0000000000..6c68999e99
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202606000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: added image editing before sending and an option for custom notification sounds, improved start times, several other 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/202606010.txt b/fastlane/metadata/android/en-US/changelogs/202606010.txt
new file mode 100644
index 0000000000..08c06cf192
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202606010.txt
@@ -0,0 +1,2 @@
+Main changes in this version: fixed an issue with devices not supporting Vulkan, added image editing before sending and an option for custom notification sounds, improved start times, several other 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/202606030.txt b/fastlane/metadata/android/en-US/changelogs/202606030.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202606030.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/202606040.txt b/fastlane/metadata/android/en-US/changelogs/202606040.txt
new file mode 100644
index 0000000000..a4b397f1bb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/202606040.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 e8f0fcb434..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 8393e45e81..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,7 +2,7 @@
"我们不会记录或分析任何个人数据"
"共享匿名使用数据以帮助我们排查问题。"
- "你可以阅读我们的所有条款 %1$s。"
+ "你可以点击 %1$s 阅读我们的所有条款。"
"此处"
"可以随时关闭此功能"
"我们不会与第三方共享你的数据"
diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt
index 5a6c135247..2093baa8b3 100644
--- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt
+++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt
@@ -35,8 +35,9 @@ class DefaultAnnouncementStore(
override fun announcementStatusFlow(announcement: Announcement): Flow {
val key = announcement.toKey()
- // Announcement.Fullscreen.Space is disabled, consider it's shown
- // For NewNotificationSound, a migration will set it to Show on application upgrade (see AppMigration08)
+ // Default-status convention: Shown for one-shot intro/feature announcements that should
+ // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted
+ // in by AppMigration08).
val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
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..e049935212 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent
import io.element.android.features.announcement.impl.AnnouncementState
@@ -20,43 +22,38 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class FullscreenAnnouncementViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class FullscreenAnnouncementViewTest : RobolectricTest() {
@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/build.gradle.kts b/features/call/impl/build.gradle.kts
index 2843e47870..d01e31451e 100644
--- a/features/call/impl/build.gradle.kts
+++ b/features/call/impl/build.gradle.kts
@@ -84,7 +84,6 @@ dependencies {
implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
implementation(libs.coil.compose)
- implementation(libs.network.retrofit)
implementation(libs.serialization.json)
implementation(libs.element.call.embedded)
api(projects.features.call.api)
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 5fa3beb36a..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,7 @@ 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
@@ -64,7 +66,6 @@ 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
@@ -80,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)
@@ -98,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
@@ -111,9 +112,30 @@ 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,
@@ -123,9 +145,8 @@ class ElementCallActivity :
) {
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()
}
}
@@ -163,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)
@@ -173,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)
@@ -188,7 +209,7 @@ class ElementCallActivity :
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
- setCallType(intent)
+ setCallData(intent)
}
override fun onDestroy() {
@@ -207,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()
@@ -236,8 +256,6 @@ class ElementCallActivity :
}
}
- private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
-
private fun registerPermissionResultLauncher(): ActivityResultLauncher> {
return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -287,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 2c4deab65e..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
@@ -118,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,
)
)
}
@@ -129,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 2d674d21af..e68e57118b 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
@@ -201,16 +201,12 @@ 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
-
if (!hasRegisteredCallbacks) {
Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks")
return
@@ -268,8 +264,8 @@ class WebViewAudioManager(
private fun setWebViewAndroidNativeBridge() {
Timber.d("Adding callback in controls.onAudioPlaybackStarted")
webView.evaluateJavascript("controls.onAudioPlaybackStarted = () => { androidNativeBridge.onTrackReady(); };", null)
- Timber.d("Adding callback in controls.onOutputDeviceSelect")
- webView.evaluateJavascript("controls.onOutputDeviceSelect = (id) => { androidNativeBridge.setOutputDevice(id); };", null)
+ Timber.d("Adding callback in controls.onAudioDeviceSelect")
+ webView.evaluateJavascript("controls.onAudioDeviceSelect = (id) => { androidNativeBridge.setAudioDevice(id); };", null)
}
/**
@@ -296,8 +292,8 @@ class WebViewAudioManager(
) {
Timber.d("Updating available audio devices")
val deviceList = json.encodeToString(devices)
- webView.evaluateJavascript("controls.setAvailableOutputDevices($deviceList);", {
- Timber.d("Audio: setAvailableOutputDevices result: $it")
+ webView.evaluateJavascript("controls.setAvailableAudioDevices($deviceList);", {
+ Timber.d("Audio: setAvailableAudioDevices result: $it")
})
}
@@ -345,6 +341,7 @@ class WebViewAudioManager(
} else {
// On Android 11 and lower, we don't have the concept of communication devices
// We have to call the right methods based on the device type
+ @Suppress("DEPRECATION")
if (device != null) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) {
Timber.w("Bluetooth audio devices are disabled on this Android version")
@@ -353,11 +350,9 @@ 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
}
@@ -402,7 +397,7 @@ private class AndroidWebViewAudioBridge(
private val onAudioPlaybackStarted: () -> Unit,
) {
@JavascriptInterface
- fun setOutputDevice(id: String) {
+ fun setAudioDevice(id: String) {
Timber.d("Audio device selected in webview, id: $id")
onAudioDeviceSelected(id)
}
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/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..9b2ee3d923 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
@@ -21,23 +21,21 @@ 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_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds
-@RunWith(RobolectricTestRunner::class)
-class DefaultElementCallEntryPointTest {
+class DefaultElementCallEntryPointTest : RobolectricTest() {
@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 +51,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/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
index c055b408dd..3f5c1c4e50 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt
@@ -23,13 +23,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-@RunWith(RobolectricTestRunner::class)
-class RingingCallNotificationCreatorTest {
+class RingingCallNotificationCreatorTest : RobolectricTest() {
@Test
fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
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..76ce8e8ede
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt
@@ -0,0 +1,149 @@
+/*
+ * 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 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 io.element.android.tests.testutils.robolectric.RobolectricTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+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)
+class CallScreenViewTest : RobolectricTest() {
+ @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..c95f8c5088 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
@@ -45,6 +45,7 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.plantTestTimber
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.verify
@@ -54,12 +55,9 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
-@RunWith(RobolectricTestRunner::class)
-class DefaultActiveCallManagerTest {
+class DefaultActiveCallManagerTest : RobolectricTest() {
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -77,7 +75,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = false,
@@ -104,7 +102,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo(
ActiveCall(
- callType = CallType.RoomCall(
+ callData = CallData(
sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId,
isAudioCall = true,
@@ -132,7 +130,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 +176,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 +186,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 +213,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 +240,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 +318,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 +327,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 +348,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 +454,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/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
index 0a7309a2b0..e03d36363d 100644
--- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
+++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt
@@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -36,6 +39,13 @@ fun AddPeopleView(
modifier: Modifier = Modifier,
invitePeopleView: @Composable () -> Unit,
) {
+ val currentOnFinish by rememberUpdatedState(onFinish)
+ LaunchedEffect(state.sendInvitesAction, state.createRoomFromDmAction) {
+ if (state.sendInvitesAction.isSuccess() || state.createRoomFromDmAction.isSuccess()) {
+ currentOnFinish()
+ }
+ }
+
HeaderFooterPage(
modifier = modifier,
contentPadding = PaddingValues(0.dp),
@@ -47,7 +57,6 @@ fun AddPeopleView(
text = stringResource(CommonStrings.action_finish),
onClick = {
state.eventSink(InvitePeopleEvents.SendInvites)
- onFinish()
},
enabled = state.canInvite,
modifier = Modifier
diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml
index 9669bb9090..6b254003b7 100644
--- a/features/createroom/impl/src/main/res/values-be/translations.xml
+++ b/features/createroom/impl/src/main/res/values-be/translations.xml
@@ -3,9 +3,15 @@
"Новы пакой"
"Запрасіць карыстальнікаў"
"Пры стварэнні пакоя адбылася памылка"
+ "Дадаць імя…"
+ "Новы пакой"
+ "Новая прастора"
"Толькі запрошаныя людзі могуць атрымаць доступ да гэтага пакоя. Усе паведамленні абаронены end-to-end шыфраваннем."
"Любы можа знайсці гэты пакой.
Вы можаце змяніць гэта ў любы час у наладах пакоя."
"Папрасіце далучыцца"
+ "Адрас"
+ "Дадаць у прастору"
"Тэма (неабавязкова)"
+ "Дадаць апісанне…"
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 4408bf66a3..c3f42ca287 100644
--- a/features/createroom/impl/src/main/res/values-de/translations.xml
+++ b/features/createroom/impl/src/main/res/values-de/translations.xml
@@ -28,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-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 fd1854db40..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"
+ "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"
"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ă."
"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-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml
index 8d898fe5f0..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,13 +1,13 @@
"新房间"
- "邀请朋友"
+ "邀请人员"
"创建房间时出错"
"由于未知错误,空间创建失败。请稍后再试。"
"添加名称…"
"新房间"
"新空间"
- "仅限受邀者加入。"
+ "仅限受邀人员加入。"
"私密"
"任何人都能找到此房间。
你可以随时在房间设置中更改。"
@@ -17,11 +17,11 @@
"申请加入"
"%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..c0c4a8a5fe 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
@@ -56,6 +56,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.mockk.mockk
import kotlinx.collections.immutable.persistentMapOf
@@ -66,8 +67,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.Optional
@@ -75,8 +74,7 @@ private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
-@RunWith(RobolectricTestRunner::class)
-class ConfigureRoomPresenterTest {
+class ConfigureRoomPresenterTest : RobolectricTest() {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -301,7 +299,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 352efdfb92..c942d2df72 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
@@ -8,7 +8,6 @@
package io.element.android.features.logout.impl
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -60,6 +59,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.PasswordVisibilityToggle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
@@ -280,14 +280,10 @@ private fun Content(
placeholder = stringResource(CommonStrings.common_password),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
- val image =
- if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
- val description =
- if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
-
- Box(modifier = Modifier.clickable { passwordVisible = !passwordVisible }) {
- Icon(imageVector = image, description)
- }
+ PasswordVisibilityToggle(
+ visible = passwordVisible,
+ onToggle = { passwordVisible = !passwordVisible },
+ )
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
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 dfbf00a1c6..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,13 +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."
+ "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 e03d53bbce..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."
- "Benutzerkonto 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-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 875142bc01..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 votre 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 9273254886..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,4 +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."
+ "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 7cd484d0e4..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."
- "Disattivazione dell\'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 f41dd1d282..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,9 +1,9 @@
- "アカウントを無効化することを再度確認します。この操作は元に戻せません。"
+ "アカウントを削除しようとしていることを確認しています。この操作は元に戻せません。"
"メッセージをすべて削除"
"注意: 新しいユーザーには断片的な会話が表示されます"
- "アカウントを無効化することは %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 42dd0aa0e2..83eaf7ff21 100644
--- a/features/deactivation/impl/src/main/res/values-ko/translations.xml
+++ b/features/deactivation/impl/src/main/res/values-ko/translations.xml
@@ -3,11 +3,12 @@
"계정을 비활성화하시겠습니까? 이 작업은 되돌릴 수 없습니다."
"모든 내 메시지 삭제"
"경고: 향후 사용자는 불완전한 대화 내용을 볼 수 있습니다."
- "계정을 비활성화하는 것은 %1$s 이며, 다음과 같은 조치를 취합니다:"
+ "계정을 삭제하면 %1$s, 다음과 같이 처리됩니다:"
"불가역적"
"%1$s 귀하의 계정 (로그인할 수 없으며, 귀하의 ID는 재사용할 수 없습니다)."
"영구적으로 비활성화"
"모든 채팅방에서 자신을 제거하세요."
"당사의 신원 서버에서 귀하의 계정 정보를 삭제하세요."
"메시지는 등록된 사용자에게는 계속 표시되지만, 삭제하면 신규 또는 미등록 사용자는 볼 수 없게 됩니다."
+ "계정 삭제"
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-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..17a5158f44 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-uz/translations.xml b/features/deactivation/impl/src/main/res/values-uz/translations.xml
index d357f38186..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,4 +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."
+ "Akkauntni o‘chirish"
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 26c942da1f..caf4c0384f 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,14 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -26,33 +28,28 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
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()
-
+class AccountDeactivationViewTest : RobolectricTest() {
@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 +57,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder,
),
)
- rule.clickOn(CommonStrings.action_delete)
+ 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 +73,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 +89,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 +116,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 +132,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/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt
index eb085d19c5..1c565a390a 100644
--- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt
+++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt
@@ -74,7 +74,10 @@ class ForwardMessagesNode(
return roomSelectEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
- params = RoomSelectEntryPoint.Params(mode = RoomSelectMode.Forward),
+ params = RoomSelectEntryPoint.Params(
+ mode = RoomSelectMode.Forward,
+ maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
+ ),
callback = callback,
)
}
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..a73478479e 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -21,34 +23,29 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ForwardMessagesViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class ForwardMessagesViewTest : RobolectricTest() {
@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 +56,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 241f73516e..0e83b29e35 100644
--- a/features/ftue/impl/src/main/res/values-de/translations.xml
+++ b/features/ftue/impl/src/main/res/values-de/translations.xml
@@ -3,7 +3,7 @@
"Bestätigung unmöglich?"
"Erstelle einen neuen Wiederherstellungsschlüssel"
"Wähle eine Verifizierungsmethode, um den sicheren Nachrichtenversand einzurichten."
- "Bestätige deine Identität"
+ "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 3e961d6249..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,7 +2,7 @@
"Kas kinnitamine pole võimalik?"
"Loo uus taastevõti"
- "Krüptitud sõnumivahetuse tagamiseks verifitseeri see seade."
+ "Turvalise sõnumside seadistamiseks vali verifitseerimise viis."
"Kinnita oma digitaalne identiteet"
"Kasuta teist seadet"
"Kasuta taastevõtit"
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-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml
index 34ac77ae3f..b7c0055b73 100644
--- a/features/ftue/impl/src/main/res/values-ru/translations.xml
+++ b/features/ftue/impl/src/main/res/values-ru/translations.xml
@@ -6,7 +6,7 @@
"Подтвердите личность"
"Использовать другое устройство"
"Использовать ключ восстановления"
- "Теперь вы можете безопасно читать и отправлять сообщения, и все, с кем вы общаетесь в чате, также могут доверять этому устройству."
+ "Теперь Вы можете безопасно читать и отправлять сообщения, и все, с кем Вы общаетесь в чате, также могут доверять этому устройству."
"Устройство проверено"
"Использовать другое устройство"
"Ожидание другого устройства…"
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 c111aecaa9..f9171c9879 100644
--- a/features/ftue/impl/src/main/res/values-zh/translations.xml
+++ b/features/ftue/impl/src/main/res/values-zh/translations.xml
@@ -9,7 +9,7 @@
"现在你可以安全地读取或发送消息,并且与你聊天的任何人也可以信任此设备。"
"设备已验证"
"使用其它设备"
- "正在等待其它设备……"
+ "正在等待其它设备…"
"你可以稍后更改设置。"
"允许通知,绝不错过任何消息"
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..66e2eb38fe 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,77 +6,74 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
-import org.junit.Rule
+import io.element.android.tests.testutils.robolectric.RobolectricTest
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()
-
+class ChooseSessionVerificationModeViewTest : RobolectricTest() {
@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..a563520e09 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
@@ -10,6 +10,7 @@ package io.element.android.features.home.impl.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -57,6 +58,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 +67,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
@@ -159,7 +161,7 @@ fun HomeTopBar(
}
@Composable
-private fun RoomListMenuItems(
+private fun RowScope.RoomListMenuItems(
onToggleSearch: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
canReportBug: Boolean,
@@ -237,6 +239,7 @@ private fun SpaceFilterButton(
else -> Unit
}
}
+
val isSelected = spaceFiltersState is SpaceFiltersState.Selected
IconButton(
onClick = ::onClick,
@@ -320,7 +323,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 +348,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 +369,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 +390,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 +411,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/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
index a03399baf7..266b6f5a59 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt
@@ -245,19 +245,25 @@ private fun RoomsViewList(
)
}
}
- SecurityBannerState.None -> if (state.fullScreenIntentPermissionsState.shouldDisplayBanner) {
- item {
- FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
+ // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
+ // new-notification-sound > sound-unavailable. At most one renders at a time.
+ SecurityBannerState.None -> when {
+ state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
+ item {
+ FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
+ }
}
- } else if (state.batteryOptimizationState.shouldDisplayBanner) {
- item {
- BatteryOptimizationBanner(state = state.batteryOptimizationState)
+ state.batteryOptimizationState.shouldDisplayBanner -> {
+ item {
+ BatteryOptimizationBanner(state = state.batteryOptimizationState)
+ }
}
- } else if (state.showNewNotificationSoundBanner) {
- item {
- NewNotificationSoundBanner(
- onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
- )
+ state.showNewNotificationSoundBanner -> {
+ item {
+ NewNotificationSoundBanner(
+ onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
+ )
+ }
}
}
}
@@ -273,6 +279,7 @@ private fun RoomsViewList(
hideInviteAvatars = hideInvitesAvatars,
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId),
+ showUnreadCount = state.showUnreadCount,
onClick = onRoomClick,
eventSink = eventSink,
)
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..ba9a204a64 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
@@ -75,8 +76,9 @@ internal fun RoomSummaryRow(
hideInviteAvatars: Boolean,
isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit,
- eventSink: (RoomListEvent) -> Unit,
modifier: Modifier = Modifier,
+ showUnreadCount: Boolean = false,
+ eventSink: (RoomListEvent) -> Unit,
) {
Box(modifier = modifier) {
when (room.displayType) {
@@ -126,7 +128,7 @@ internal fun RoomSummaryRow(
timestamp = room.timestamp,
isHighlighted = room.isHighlighted
)
- MessagePreviewAndIndicatorRow(room = room)
+ MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount)
}
}
RoomSummaryDisplayType.KNOCKED -> {
@@ -222,20 +224,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 +261,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,
)
}
}
@@ -275,6 +274,7 @@ private fun InviteSubtitle(
@Composable
private fun MessagePreviewAndIndicatorRow(
room: RoomListRoomSummary,
+ showUnreadCount: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@@ -326,7 +326,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,
@@ -360,8 +362,18 @@ private fun MessagePreviewAndIndicatorRow(
}
if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
+ val count = if (showUnreadCount) {
+ if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
+ room.numberOfUnreadMessages
+ } else {
+ room.numberOfUnreadNotifications
+ }
+ } else {
+ null
+ }
UnreadIndicatorAtom(
color = tint,
+ count = count,
contentDescription = contentDescription,
)
}
@@ -381,7 +393,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 = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
@@ -43,6 +44,7 @@ internal fun aRoomsContentState(
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
+ showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState,
summaries = summaries,
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
index 66982961fd..e760379d7b 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt
@@ -69,10 +69,6 @@ fun RoomListContextMenu(
onFavoriteChange = { isFavorite ->
eventSink(RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
- onClearCacheRoomClick = {
- eventSink(RoomListEvent.HideContextMenu)
- eventSink(RoomListEvent.ClearCacheOfRoom(contextMenu.roomId))
- },
onReportRoomClick = {
eventSink(RoomListEvent.HideContextMenu)
onReportRoomClick(contextMenu.roomId)
@@ -90,7 +86,6 @@ private fun RoomListModalBottomSheetContent(
onFavoriteChange: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit,
- onClearCacheRoomClick: () -> Unit,
onReportRoomClick: () -> Unit,
) {
Column(
@@ -119,7 +114,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsRead())
),
- style = ListItemStyle.Primary,
)
} else {
ListItem(
@@ -133,7 +127,6 @@ private fun RoomListModalBottomSheetContent(
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.MarkAsUnread())
),
- style = ListItemStyle.Primary,
)
}
val (textResId, icon) = if (contextMenu.isFavorite) {
@@ -159,7 +152,6 @@ private fun RoomListModalBottomSheetContent(
onClick = {
onFavoriteChange(!contextMenu.isFavorite)
},
- style = ListItemStyle.Primary,
)
ListItem(
headlineContent = {
@@ -174,7 +166,6 @@ private fun RoomListModalBottomSheetContent(
CompoundIcons.Settings(),
)
),
- style = ListItemStyle.Primary,
)
if (canReportRoom) {
ListItem(
@@ -202,18 +193,6 @@ private fun RoomListModalBottomSheetContent(
),
style = ListItemStyle.Destructive,
)
- if (contextMenu.displayClearRoomCacheAction) {
- ListItem(
- headlineContent = {
- Text(text = "Clear cache for this room")
- },
- modifier = Modifier.clickable { onClearCacheRoomClick() },
- leadingContent = ListItemContent.Icon(
- iconSource = IconSource.Vector(CompoundIcons.Delete())
- ),
- style = ListItemStyle.Primary,
- )
- }
}
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt
index 3e82ff9809..49d033682f 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt
@@ -30,5 +30,4 @@ sealed interface RoomListEvent {
data class MarkAsRead(val roomId: RoomId) : ContextMenuEvent
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvent
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvent
- data class ClearCacheOfRoom(val roomId: RoomId) : ContextMenuEvent
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
index 2010555cd7..128e663f61 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt
@@ -28,7 +28,6 @@ import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource
-import io.element.android.features.home.impl.filters.RoomListFilter.Rooms
import io.element.android.features.home.impl.filters.RoomListFiltersState
import io.element.android.features.home.impl.filters.into
import io.element.android.features.home.impl.search.RoomListSearchEvent
@@ -44,6 +43,8 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -52,7 +53,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
-import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
@@ -85,11 +85,11 @@ class RoomListPresenter(
private val fullScreenIntentPermissionsPresenter: Presenter,
private val batteryOptimizationPresenter: Presenter,
private val notificationCleaner: NotificationCleaner,
- private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
private val spaceFiltersPresenter: Presenter,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
private val encryptionService = client.encryptionService
@@ -154,7 +154,6 @@ class RoomListPresenter(
}
is RoomListEvent.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvent.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
- is RoomListEvent.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
}
}
@@ -165,13 +164,17 @@ class RoomListPresenter(
roomListDataSource.updateFilter(allFilters)
}
+ val canReportRoom by produceState(false) { value = client.canReportRoom() }
+ val showUnreadCount by produceState(false) {
+ value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount)
+ }
+
val contentState = roomListContentState(
securityBannerDismissed,
showNewNotificationSoundBanner,
+ showUnreadCount,
)
- val canReportRoom by produceState(false) { value = client.canReportRoom() }
-
return RoomListState(
contextMenu = contextMenu.value,
declineInviteMenu = declineInviteMenu.value,
@@ -226,6 +229,7 @@ class RoomListPresenter(
private fun roomListContentState(
securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean,
+ showUnreadCount: Boolean,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -254,6 +258,7 @@ class RoomListPresenter(
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
+ showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
@@ -271,7 +276,6 @@ class RoomListPresenter(
isDm = event.roomSummary.isDm,
isFavorite = event.roomSummary.isFavorite,
hasNewContent = event.roomSummary.hasNewContent,
- displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(),
)
contextMenuState.value = initialState
@@ -327,10 +331,4 @@ class RoomListPresenter(
}
}
}
-
- private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
- client.getRoom(roomId)?.use { room ->
- room.clearEventCacheStorage()
- }
- }
}
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
index e0f4943621..e184a5a57f 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt
@@ -44,7 +44,6 @@ data class RoomListState(
val isDm: Boolean,
val isFavorite: Boolean,
val hasNewContent: Boolean,
- val displayClearRoomCacheAction: Boolean,
) : ContextMenu
}
@@ -72,6 +71,7 @@ sealed interface RoomListContentState {
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState,
val showNewNotificationSoundBanner: Boolean,
+ val showUnreadCount: Boolean,
val summaries: ImmutableList,
val seenRoomInvites: ImmutableSet,
) : RoomListContentState
diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
index a1624e4917..eca1b63d7d 100644
--- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
+++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateContextMenuShownProvider.kt
@@ -31,5 +31,4 @@ internal fun aContextMenuShown(
isDm = isDm,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
- displayClearRoomCacheAction = false,
)
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 1f0d6ff7d9..8a270f1b85 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
@@ -48,6 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.spaces.SpaceServiceFilter
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -93,7 +94,7 @@ fun SpaceFiltersView(
SpaceFiltersBottomSheetContent(
filters = state.visibleFilters,
searchQuery = state.searchQuery,
- onFilterSelected = { filter ->
+ onSelectFilter = { filter ->
state.eventSink(SpaceFiltersEvent.Selecting.SelectFilter(filter))
}
)
@@ -105,9 +106,9 @@ fun SpaceFiltersView(
@Composable
private fun SpaceFiltersBottomSheetContent(
- filters: List,
+ filters: ImmutableList,
searchQuery: TextFieldState,
- onFilterSelected: (SpaceServiceFilter) -> Unit,
+ onSelectFilter: (SpaceServiceFilter) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@@ -133,7 +134,7 @@ private fun SpaceFiltersBottomSheetContent(
items(filters) { filter ->
SpaceFilterItem(
filter = filter,
- onClick = { onFilterSelected(filter) }
+ onClick = { onSelectFilter(filter) }
)
}
}
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-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-ja/translations.xml b/features/home/impl/src/main/res/values-ja/translations.xml
index c1c1ade1ff..855bb14533 100644
--- a/features/home/impl/src/main/res/values-ja/translations.xml
+++ b/features/home/impl/src/main/res/values-ja/translations.xml
@@ -1,7 +1,7 @@
"すべての通知を確実に受信するために、このアプリのバッテリー最適化を無効にしてください。"
- "最適化を無効にする"
+ "無効にする"
"通知が届いていませんか?"
"通知音が更新され、より明確で速く、そして邪魔にならなくなりました。"
"サウンドを刷新しました"
@@ -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 be1933e160..14cf100c22 100644
--- a/features/home/impl/src/main/res/values-ro/translations.xml
+++ b/features/home/impl/src/main/res/values-ro/translations.xml
@@ -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-ru/translations.xml b/features/home/impl/src/main/res/values-ru/translations.xml
index 233e8749b4..84a604e184 100644
--- a/features/home/impl/src/main/res/values-ru/translations.xml
+++ b/features/home/impl/src/main/res/values-ru/translations.xml
@@ -21,7 +21,7 @@
"Вы уверены, что хотите отказаться от личного общения с %1$s?"
"Отклонить чат"
"Нет приглашений"
- "%1$s (%2$s) пригласил(а) вас"
+ "%1$s (%2$s) пригласил(а) Вас"
"Это единоразовая процедура, спасибо, что подождали."
"Настройка Вашего аккаунта."
"Создать новую беседу или комнату"
@@ -31,20 +31,20 @@
"Избранные"
"Добавить чат в избранное можно в настройках чата.
На данный момент вы можете убрать фильтры, чтобы увидеть другие ваши чаты."
- "У вас пока нет избранных чатов"
+ "У Вас пока нет избранных чатов"
"Приглашения"
- "У вас нет приглашений."
+ "У Вас нет приглашений."
"Низкий приоритет"
- "У вас пока нет чатов с низким приоритетом."
+ "У Вас пока нет чатов с низким приоритетом."
"Вы можете убрать фильтры, чтобы увидеть другие ваши чаты."
- "У вас нет чатов, соответствующих фильтрам"
+ "У Вас нет чатов, соответствующих фильтрам"
"Пользователи"
- "У вас пока нет личных сообщений"
+ "У Вас пока нет личных сообщений"
"Комнаты"
"Вас пока нет ни в одной комнате"
"Новые"
"Поздравляем!
-У вас нет непрочитанных сообщений!"
+У Вас нет непрочитанных сообщений!"
"Запрос на присоединение отправлен"
"Чаты"
"Пометить как прочитанное"
diff --git a/features/home/impl/src/main/res/values-sk/translations.xml b/features/home/impl/src/main/res/values-sk/translations.xml
index 12e6284468..aab1f6dc1d 100644
--- a/features/home/impl/src/main/res/values-sk/translations.xml
+++ b/features/home/impl/src/main/res/values-sk/translations.xml
@@ -6,7 +6,7 @@
"Vaše oznámenia boli aktualizované – sú prehľadnejšie, rýchlejšie a menej rušivé."
"Obnovili sme vaše zvuky"
"Vytvorte nový kľúč na obnovenie, ktorý môžete použiť na obnovenie vašej histórie šifrovaných správ v prípade straty prístupu k vašim zariadeniam."
- "Nastaviť obnovenie"
+ "Získať kľúč na obnovenie"
"Nastaviť obnovenie"
"Potvrďte svoj kľúč na obnovenie, aby ste zachovali prístup k úložisku kľúčov a histórii správ."
"Zadajte kľúč na obnovenie"
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 4bc15b08cf..575adc6bb9 100644
--- a/features/home/impl/src/main/res/values-zh/translations.xml
+++ b/features/home/impl/src/main/res/values-zh/translations.xml
@@ -4,7 +4,7 @@
"禁用优化"
"通知未送达?"
"通知提示音已升级:更清晰、更快速、干扰更少。"
- "我们已更新你的声音"
+ "我们已更新提示音"
"你的聊天已被端到端加密自动备份。如果你无法访问所有设备,则需要使用恢复密钥并保留数字身份。"
"获取恢复密钥"
"备份聊天"
@@ -21,14 +21,14 @@
"你确定要拒绝与 %1$s 私聊?"
"拒绝聊天"
"没有邀请"
- "%1$s (%2$s)邀请了你"
+ "%1$s(%2$s)邀请了你"
"此为一次性流程,感谢等待。"
"设置账户。"
"创建新的对话或房间"
"清除筛选条件"
"通过向某人发送消息来开始。"
"暂无聊天。"
- "收藏夹"
+ "收藏"
"可以在聊天设置里将聊天添加到收藏夹。
现在可以取消选择筛选器以查看其它对话。"
"你尚未收藏任何聊天"
@@ -38,7 +38,7 @@
"你暂无任何低优先级聊天"
"你可以取消选择筛选器以查看其它对话"
"你暂无适用于此选项的聊天"
- "用户"
+ "人员"
"你暂无任何私聊"
"房间"
"你尚未进入任何房间"
diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
index 582de66414..8eee8e1ee2 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt
@@ -8,7 +8,6 @@
package io.element.android.features.home.impl
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint
@@ -18,12 +17,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-@RunWith(AndroidJUnit4::class)
-class DefaultHomeEntryPointTest {
+class DefaultHomeEntryPointTest : RobolectricTest() {
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultHomeEntryPoint()
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..6d68491ad1 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,34 +6,32 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.selection.FilterSelectionState
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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class RoomListFiltersViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class RoomListFiltersViewTest : RobolectricTest() {
@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 +40,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 +50,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..9ebad7f7f6 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.R
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@@ -19,24 +21,20 @@ import io.element.android.tests.testutils.EnsureCalledOnceWithParam
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.robolectric.RobolectricTest
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()
+class RoomListContextMenuTest : RobolectricTest() {
@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 +44,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 +61,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 +78,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 +127,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..cb0226353b 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,31 +6,28 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
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.robolectric.RobolectricTest
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()
+class RoomListDeclineInviteMenuTest : RobolectricTest() {
@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 +35,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder,
)
}
- rule.clickOn(CommonStrings.action_decline)
+ clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
RoomListEvent.HideDeclineInviteMenu,
@@ -48,10 +45,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 +56,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 +73,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 +82,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 +93,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/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
index 99a5550a08..642fd462d7 100644
--- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
+++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt
@@ -36,6 +36,8 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
@@ -61,9 +63,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
-import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
-import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
@@ -89,6 +89,7 @@ import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
+@Suppress("LargeClass")
class RoomListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -227,7 +228,6 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
hasNewContent = false,
- displayClearRoomCacheAction = false,
)
)
}
@@ -244,35 +244,6 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = true,
hasNewContent = false,
- displayClearRoomCacheAction = false,
- )
- )
- }
- }
- }
-
- @Test
- fun `present - show context menu with view source on`() = runTest {
- val presenter = createRoomListPresenter(
- appPreferencesStore = InMemoryAppPreferencesStore(
- isDeveloperModeEnabled = true,
- )
- )
- presenter.test {
- val initialState = awaitItem()
- val summary = createRoomListRoomSummary()
- initialState.eventSink(RoomListEvent.ShowContextMenu(summary))
- awaitItem().also { state ->
- assertThat(state.contextMenu)
- .isEqualTo(
- RoomListState.ContextMenu.Shown(
- roomId = summary.roomId,
- roomName = summary.name,
- isDm = false,
- isFavorite = false,
- // true here.
- hasNewContent = false,
- displayClearRoomCacheAction = true,
)
)
}
@@ -300,7 +271,6 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
hasNewContent = false,
- displayClearRoomCacheAction = false,
)
)
@@ -665,9 +635,9 @@ class RoomListPresenterTest {
spaceFiltersPresenter: Presenter = Presenter { aDisabledSpaceFiltersState() },
acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
- appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
+ featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
@@ -692,9 +662,9 @@ class RoomListPresenterTest {
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner,
- appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore,
announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
+ featureFlagService = featureFlagService,
)
}
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..773f428917 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,17 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.aHomeState
@@ -31,23 +33,17 @@ 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.ensureCalledOnceWithParam
+import io.element.android.tests.testutils.robolectric.RobolectricTest
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()
-
+class RoomListViewTest : RobolectricTest() {
@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 +58,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 +70,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 +88,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 +108,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 +127,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 +157,7 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.ROOM
}
ensureCalledOnceWithParam(room0.roomId) { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = state,
onRoomClick = callback,
)
@@ -169,14 +165,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 +181,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 +195,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 +203,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 +222,7 @@ class RoomListViewTest {
)
val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId
ensureCalledOnceWithParam(room0) { callback ->
- rule.setRoomListView(
+ setRoomListView(
state = state,
onRoomSettingsClick = callback,
)
@@ -234,14 +230,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 +245,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 +261,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..9c9d13d066 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,30 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.tests.testutils.EventsRecorder
-import org.junit.Rule
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class SpaceFiltersViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class SpaceFiltersViewTest : RobolectricTest() {
@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 +36,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 +57,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-ru/translations.xml b/features/invite/impl/src/main/res/values-ru/translations.xml
index 4e1104266e..8139beb885 100644
--- a/features/invite/impl/src/main/res/values-ru/translations.xml
+++ b/features/invite/impl/src/main/res/values-ru/translations.xml
@@ -10,7 +10,7 @@
"Вы уверены, что хотите отказаться от личного общения с %1$s?"
"Отклонить чат"
"Нет приглашений"
- "%1$s (%2$s) пригласил(а) вас"
+ "%1$s (%2$s) пригласил(а) Вас"
"Да, отклонить и заблокировать"
"Вы действительно хотите отклонить приглашение в комнату? Также %1$s больше не сможет связаться с вами или приглашать в комнаты."
"Отклонить приглашение и заблокировать"
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 61e7f15f59..02825bca6d 100644
--- a/features/invite/impl/src/main/res/values-zh/translations.xml
+++ b/features/invite/impl/src/main/res/values-zh/translations.xml
@@ -10,7 +10,7 @@
"你确定要拒绝与 %1$s 私聊?"
"拒绝聊天"
"没有邀请"
- "%1$s (%2$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..55c31a09d2 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,14 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.invite.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@@ -21,98 +23,93 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class DeclineAndBlockViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class DeclineAndBlockViewTest : RobolectricTest() {
@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 185497c25b..2ab2fcb4a3 100644
--- a/features/invitepeople/impl/build.gradle.kts
+++ b/features/invitepeople/impl/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
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 b223d30617..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
@@ -38,12 +38,17 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
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
@@ -88,6 +93,7 @@ class DefaultInvitePeoplePresenter(
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
+ val createRoomFromDmAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val recentDirectRooms by produceState(emptyList(), roomMembers.value) {
if (roomMembers.value.isSuccess()) {
@@ -208,7 +214,13 @@ class DefaultInvitePeoplePresenter(
)
} 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)
+ }
+ }
}
}
}
@@ -216,6 +228,10 @@ class DefaultInvitePeoplePresenter(
searchActive = false
queryState.clearText()
}
+ is InvitePeopleEvents.ClearError -> {
+ sendInvitesAction.value = AsyncAction.Uninitialized
+ createRoomFromDmAction.value = AsyncAction.Uninitialized
+ }
}
}
@@ -228,6 +244,7 @@ class DefaultInvitePeoplePresenter(
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
+ createRoomFromDmAction = createRoomFromDmAction.value,
suggestions = suggestions,
eventSink = ::handleEvent,
)
@@ -254,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 b1f139f43c..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,6 +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.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -54,6 +56,8 @@ 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
@@ -102,7 +106,7 @@ private fun InvitePeopleContentView(
}
InvitePeopleSearchBar(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.imePadding().fillMaxWidth(),
queryState = state.searchQuery,
showLoader = state.showSearchLoader,
selectedUsers = state.selectedUsers,
@@ -298,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-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-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-ko/translations.xml b/features/invitepeople/impl/src/main/res/values-ko/translations.xml
index 19214c5a79..421f6d5e02 100644
--- a/features/invitepeople/impl/src/main/res/values-ko/translations.xml
+++ b/features/invitepeople/impl/src/main/res/values-ko/translations.xml
@@ -2,4 +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..651ec33626 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/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 e7fef11423..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
@@ -320,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))
@@ -358,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")
@@ -540,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 {
@@ -554,7 +554,7 @@ internal class DefaultInvitePeoplePresenterTest {
roomId = dmRoomId,
initialRoomInfo = aRoomInfo(
id = dmRoomId,
- isDirect = true,
+ isDm = true,
activeMembersCount = 2,
currentUserMembership = CurrentUserMembership.JOINED,
),
@@ -591,7 +591,7 @@ internal class DefaultInvitePeoplePresenterTest {
roomId = dmRoomId,
initialRoomInfo = aRoomInfo(
id = dmRoomId,
- isDirect = true,
+ isDm = true,
activeMembersCount = 2,
currentUserMembership = CurrentUserMembership.JOINED,
),
@@ -831,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
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-be/translations.xml b/features/joinroom/impl/src/main/res/values-be/translations.xml
index f166186a1b..3f8e2bac2b 100644
--- a/features/joinroom/impl/src/main/res/values-be/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-be/translations.xml
@@ -1,5 +1,6 @@
+ "Так, адмяніць"
"Адхіліць і заблакіраваць"
"Далучыцца"
"Націсніце, каб далучыцца"
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-ru/translations.xml b/features/joinroom/impl/src/main/res/values-ru/translations.xml
index 9add5f4790..eda089b5c2 100644
--- a/features/joinroom/impl/src/main/res/values-ru/translations.xml
+++ b/features/joinroom/impl/src/main/res/values-ru/translations.xml
@@ -1,6 +1,6 @@
- "%1$s заблокировал(а) вас в комнате."
+ "%1$s заблокировал(а) Вас в комнате."
"Вы были заблокированы в комнате"
"Причина: %1$s."
"Отменить запрос"
@@ -27,7 +27,7 @@
"Не удалось показать предпросмотр комнаты"
"%1$s еще не поддерживает пространства. Вы можете получить к ним доступ в веб-версии."
"Пространства пока не поддерживаются"
- "Нажмите кнопку ниже и администратор комнаты получит уведомление. После одобрения вы сможете присоединиться к обсуждению."
+ "Нажмите кнопку ниже и администратор комнаты получит уведомление. После одобрения Вы сможете присоединиться к беседе."
"Вы должны быть участником этой комнаты, чтобы просмотреть историю сообщений."
"Хотите присоединиться к этой комнате?"
"Предпросмотр недоступен"
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/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..0c510cacdb 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.test.anInviteData
import io.element.android.libraries.architecture.AsyncAction
@@ -26,116 +28,111 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class JoinRoomViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class JoinRoomViewTest : RobolectricTest() {
@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 +143,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 +199,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 +229,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-be/translations.xml b/features/knockrequests/impl/src/main/res/values-be/translations.xml
index e6f08bc320..5fcb54e9af 100644
--- a/features/knockrequests/impl/src/main/res/values-be/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-be/translations.xml
@@ -1,4 +1,6 @@
+ "Адхіліць доступ"
+ "Паглядзець усе"
"Прыняць"
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-ru/translations.xml b/features/knockrequests/impl/src/main/res/values-ru/translations.xml
index 22d400cc50..ee3c31ebb3 100644
--- a/features/knockrequests/impl/src/main/res/values-ru/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-ru/translations.xml
@@ -4,10 +4,10 @@
"Вы действительно хотите принять все заявки на присоединение?"
"Принять все запросы"
"Принять все"
- "Мы не смогли принять все запросы. Попробовать еще раз?"
+ "Мы не смогли принять все запросы. Попробовать ещё раз?"
"Не удалось принять все запросы"
"Принять все заявки на присоединение"
- "Мы не смогли принять этот запрос. Хотите попробовать еще раз?"
+ "Мы не смогли принять этот запрос. Хотите попробовать ещё раз?"
"Не удалось принять запрос"
"Принятие заявки на присоединение"
"Да, отклонить и запретить"
@@ -18,7 +18,7 @@
"Вы уверены, что хотите отклонить запрос %1$s на присоединение к этой комнате?"
"Отклонить доступ"
"Отклонить и запретить"
- "Мы не смогли отклонить этот запрос. Попробовать еще раз?"
+ "Мы не смогли отклонить этот запрос. Попробовать ещё раз?"
"Не удалось отклонить запрос"
"Отклонение заявки на присоединение"
"Вы сможете увидеть запрос, когда кто-то попросит присоединиться к комнате."
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 d70632861c..d8b8f76b13 100644
--- a/features/knockrequests/impl/src/main/res/values-zh/translations.xml
+++ b/features/knockrequests/impl/src/main/res/values-zh/translations.xml
@@ -17,7 +17,7 @@
"是,拒绝"
"你确定要拒绝 %1$s 加入此房间的申请?"
"拒绝访问"
- "拒绝和禁止"
+ "拒绝并封禁"
"我们无法拒绝此申请。是否重试?"
"拒绝申请失败"
"拒绝加入申请"
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..f55ccb0cef 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,14 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.ui.strings.CommonStrings
@@ -21,35 +23,29 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class KnockRequestsBannerViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class KnockRequestsBannerViewTest : RobolectricTest() {
@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 +56,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..1fbcb67b21 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction
@@ -22,91 +24,86 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
+import io.element.android.tests.testutils.robolectric.RobolectricTest
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()
+class KnockRequestsListViewTest : RobolectricTest() {
@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 +111,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 +127,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 +143,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-ru/translations.xml b/features/leaveroom/api/src/main/res/values-ru/translations.xml
index 898dd684b8..4fc11fccf4 100644
--- a/features/leaveroom/api/src/main/res/values-ru/translations.xml
+++ b/features/leaveroom/api/src/main/res/values-ru/translations.xml
@@ -1,7 +1,7 @@
- "Вы уверены, что хотите покинуть беседу? Эта беседа не является общедоступной, и вы не сможете присоединиться к ней без приглашения."
- "Вы уверены, что хотите покинуть эту комнату? Здесь есть только вы. Если вы покинете комнату, никто не сможет присоединиться к ней в будущем, включая вас."
+ "Вы уверены, что хотите покинуть беседу? Эта беседа не является общедоступной, и Вы не сможете снова присоединиться к ней без приглашения."
+ "Вы уверены, что хотите покинуть эту комнату? Здесь есть только Вы. Если Вы покинете комнату, никто не сможет присоединиться к ней в будущем, включая Вас."
"Вы уверены, что хотите покинуть эту комнату? Эта комната не является общедоступной, и Вы не сможете присоединиться к ней без приглашения."
"Назначить владельцев"
"Вы единственный владелец этой комнаты. Перед тем, как её покинуть, необходимо передать владение кому-нибудь другому."
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..4659a2d2b8 100644
--- a/features/linknewdevice/impl/build.gradle.kts
+++ b/features/linknewdevice/impl/build.gradle.kts
@@ -30,6 +30,7 @@ dependencies {
// TODO Cleanup
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
+ implementation(projects.features.lockscreen.api)
implementation(projects.features.rageshake.api)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
@@ -43,7 +44,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)
@@ -53,10 +54,11 @@ dependencies {
testCommonDependencies(libs, true)
testImplementation(projects.features.linknewdevice.test)
+ testImplementation(projects.features.lockscreen.test)
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..d686b22860 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
@@ -13,8 +13,10 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
@@ -26,14 +28,18 @@ 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
import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode
import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode
+import io.element.android.features.linknewdevice.impl.screens.root.LinkDeviceType
import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode
import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
+import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
@@ -64,11 +70,17 @@ class LinkNewDeviceFlowNode(
private val sessionCoroutineScope: CoroutineScope,
private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler,
+ private val sessionEnterpriseService: SessionEnterpriseService,
+ private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
) : BaseFlowNode(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
+ permanentNavModel = PermanentNavModel(
+ navTargets = setOf(NavTarget.LockScreen),
+ savedStateMap = buildContext.savedStateMap,
+ ),
buildContext = buildContext,
plugins = plugins,
) {
@@ -107,6 +119,11 @@ class LinkNewDeviceFlowNode(
val data: String,
) : NavTarget
+ @Parcelize
+ data class CodeConfirmation(
+ val code: String,
+ ) : NavTarget
+
@Parcelize
data object MobileEnterNumber : NavTarget
@@ -116,6 +133,9 @@ class LinkNewDeviceFlowNode(
@Parcelize
data object DesktopScanQrCode : NavTarget
+ @Parcelize
+ data object LockScreen : NavTarget
+
@Parcelize
data class Error(
val errorScreenType: ErrorScreenType,
@@ -129,6 +149,9 @@ class LinkNewDeviceFlowNode(
Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
when (linkMobileStep) {
LinkMobileStep.Uninitialized -> Unit
+ LinkMobileStep.CreatingQrCode -> {
+ // This step is handled in LinkNewDeviceRootPresenter
+ }
LinkMobileStep.Done -> {
callback.onDone()
}
@@ -136,8 +159,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 +174,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 +192,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 +211,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.
@@ -211,13 +239,28 @@ class LinkNewDeviceFlowNode(
callback.onDone()
}
- override fun linkDesktopDevice() {
- linkNewDesktopHandler.reset()
- backstack.push(NavTarget.DesktopNotice)
+ override fun onUnlockDevice(type: LinkDeviceType) {
+ val callback = object : DeviceUnlockEntryPoint.Callback {
+ override fun onCancel() = Unit
+ override fun onUnlock() = when (type) {
+ LinkDeviceType.Mobile -> {
+ linkNewMobileHandler.reset()
+ linkNewMobileHandler.createAndStartNewHandler()
+ }
+ LinkDeviceType.Desktop -> {
+ linkNewDesktopHandler.reset()
+ backstack.push(NavTarget.DesktopNotice)
+ }
+ }
+ }
+ deviceUnlockEntryPoint.requestUnlock(callback)
}
}
createNode(buildContext, listOf(callback))
}
+ is NavTarget.LockScreen -> {
+ deviceUnlockEntryPoint.createNode(this, buildContext)
+ }
NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() {
@@ -250,6 +293,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 +336,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
@@ -295,5 +354,6 @@ class LinkNewDeviceFlowNode(
}
}
BackstackView()
+ PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
}
}
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..39c9c6fef3 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
@@ -42,12 +43,15 @@ class LinkNewMobileHandler(
val stepFlow: StateFlow
get() = linkMobileStepFlow.asStateFlow()
- fun createAndStartNewHandler() {
+ fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel()
handler = matrixClient.createLinkMobileHandler().getOrNull()
handler?.let { h ->
currentJob = sessionScope.launch {
+ if (!forRotating) {
+ linkMobileStepFlow.emit(LinkMobileStep.CreatingQrCode)
+ }
h.linkMobileStep
.onEach {
linkMobileStepFlow.emit(it)
@@ -65,4 +69,15 @@ class LinkNewMobileHandler(
linkMobileStepFlow.emit(LinkMobileStep.Uninitialized)
}
}
+
+ fun rotateQrCode() {
+ createAndStartNewHandler(forRotating = true)
+ }
+
+ 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..7b830626f6 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,43 +47,54 @@ 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,
+ )
+ }
},
)
}
@Composable
private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
- ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title)
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_link_new_device_error_request_cancelled_title)
ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title)
- ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title)
- ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_link_new_device_error_request_timeout_title)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_link_new_device_error_not_supported_title)
ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title)
ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title)
ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName)
is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong)
+ ErrorScreenType.OtherDeviceAlreadySignedIn -> stringResource(R.string.screen_qr_code_login_error_device_already_signed_in_title)
}
@Composable
private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) {
- ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle)
+ ErrorScreenType.Cancelled -> stringResource(R.string.screen_link_new_device_error_request_cancelled_subtitle)
ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle)
- ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle)
- ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName)
+ ErrorScreenType.Expired -> stringResource(R.string.screen_link_new_device_error_request_timeout_subtitle)
+ ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_link_new_device_error_not_supported_subtitle)
ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_subtitle)
ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description)
ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName)
is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description)
+ ErrorScreenType.OtherDeviceAlreadySignedIn -> stringResource(R.string.screen_qr_code_login_error_device_already_signed_in_subtitle)
}
@Composable
@@ -125,7 +136,18 @@ private fun Content(errorScreenType: ErrorScreenType) {
}
@Composable
-private fun Buttons(
+private fun DoneButton(
+ onDone: () -> Unit,
+) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(CommonStrings.action_done),
+ onClick = onDone,
+ )
+}
+
+@Composable
+private fun ColumnScope.Buttons(
onRetry: () -> Unit,
onCancel: () -> 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/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/UserCertificatesProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt
similarity index 60%
rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/UserCertificatesProvider.kt
rename to features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt
index 529dd4eb4e..6b26fe5e79 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/UserCertificatesProvider.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkDeviceType.kt
@@ -5,8 +5,9 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.matrix.impl.certificates
+package io.element.android.features.linknewdevice.impl.screens.root
-interface UserCertificatesProvider {
- fun provides(): List
+enum class LinkDeviceType {
+ Mobile,
+ Desktop,
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
index 8ce6af90b0..97047fea9d 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt
@@ -8,6 +8,5 @@
package io.element.android.features.linknewdevice.impl.screens.root
sealed interface LinkNewDeviceRootEvent {
- data object LinkMobileDevice : LinkNewDeviceRootEvent
data object CloseDialog : LinkNewDeviceRootEvent
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
index f43ffa64df..dc74f1287f 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt
@@ -27,7 +27,7 @@ class LinkNewDeviceRootNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
- fun linkDesktopDevice()
+ fun onUnlockDevice(type: LinkDeviceType)
}
private val callback: Callback = callback()
@@ -39,7 +39,7 @@ class LinkNewDeviceRootNode(
state = state,
modifier = modifier,
onBackClick = callback::onDone,
- onLinkDesktopDeviceClick = callback::linkDesktopDevice,
+ onUnlockDevice = callback::onUnlockDevice,
)
}
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
index a17ed88fd3..af3ede0c27 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt
@@ -50,7 +50,13 @@ class LinkNewDeviceRootPresenter(
LaunchedEffect(step) {
when (val finalStep = step) {
is LinkMobileStep.Uninitialized -> {
- qrCodeData = AsyncData.Uninitialized
+ // Ignore this step when loading QrCode
+ if (!qrCodeData.isLoading()) {
+ qrCodeData = AsyncData.Uninitialized
+ }
+ }
+ is LinkMobileStep.CreatingQrCode -> {
+ qrCodeData = AsyncData.Loading()
}
is LinkMobileStep.QrReady -> {
qrCodeData = AsyncData.Success(Unit)
@@ -64,12 +70,6 @@ class LinkNewDeviceRootPresenter(
fun handleEvent(event: LinkNewDeviceRootEvent) {
when (event) {
- LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
- qrCodeData = AsyncData.Loading()
- // Wait for the QrCode to be ready
- linkNewMobileHandler.reset()
- linkNewMobileHandler.createAndStartNewHandler()
- }
LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
linkNewMobileHandler.reset()
}
diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
index d9249d3e89..5778abfb2c 100644
--- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
+++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt
@@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LinkNewDeviceRootView(
state: LinkNewDeviceRootState,
onBackClick: () -> Unit,
- onLinkDesktopDeviceClick: () -> Unit,
+ onUnlockDevice: (type: LinkDeviceType) -> Unit,
modifier: Modifier = Modifier,
) {
val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
@@ -57,6 +57,7 @@ fun LinkNewDeviceRootView(
BigIcon.Style.Default(CompoundIcons.Devices())
)
}
+
FlowStepPage(
onBackClick = onBackClick,
title = title,
@@ -83,40 +84,37 @@ fun LinkNewDeviceRootView(
}
is AsyncData.Success -> {
if (state.isSupported.data) {
- when (state.qrCodeData) {
- AsyncData.Uninitialized,
- is AsyncData.Failure -> {
- Button(
- onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
- text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
- modifier = Modifier.fillMaxWidth(),
- leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
- )
- Button(
- onClick = onLinkDesktopDeviceClick,
- text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
- modifier = Modifier.fillMaxWidth(),
- leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
- )
- }
- is AsyncData.Loading,
- is AsyncData.Success -> {
- Button(
- onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
- text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
- showProgress = true,
- enabled = false,
- modifier = Modifier.fillMaxWidth(),
- )
- Button(
- onClick = onLinkDesktopDeviceClick,
- text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
- modifier = Modifier.fillMaxWidth(),
- enabled = false,
- leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
- )
- }
- }
+ val canClick = state.qrCodeData is AsyncData.Uninitialized
+ val isLoading = state.qrCodeData is AsyncData.Loading || state.qrCodeData is AsyncData.Success
+ Button(
+ onClick = {
+ if (canClick) {
+ onUnlockDevice(LinkDeviceType.Mobile)
+ }
+ },
+ text = stringResource(
+ id = if (isLoading) {
+ R.string.screen_link_new_device_root_loading_qr_code
+ } else {
+ R.string.screen_link_new_device_root_mobile_device
+ }
+ ),
+ showProgress = isLoading,
+ enabled = !isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
+ )
+ Button(
+ onClick = {
+ if (canClick) {
+ onUnlockDevice(LinkDeviceType.Desktop)
+ }
+ },
+ text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
+ enabled = !isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
+ )
} else {
Button(
onClick = onBackClick,
@@ -147,6 +145,6 @@ internal fun LinkNewDeviceRootViewPreview(
LinkNewDeviceRootView(
state = state,
onBackClick = { },
- onLinkDesktopDeviceClick = { },
+ onUnlockDevice = { },
)
}
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..1382829c04 100644
--- a/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml
@@ -15,7 +15,7 @@
"QR-код не поддерживается"
"Вход на другом устройстве был отменен."
"Запрос на вход отменен"
- "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Срок действия входа истек. Пожалуйста, попробуйте ещё раз."
"Вход в систему не был выполнен вовремя"
"Откройте %1$s на другом устройстве"
"Выберите %1$s"
@@ -26,7 +26,7 @@
"Загрузка QR-кода…"
"Смартфон"
"Какое устройство вы хотите привязать?"
- "Пожалуйста, попробуйте еще раз и убедитесь, что вы правильно ввели 2-значный код. Если цифры по-прежнему не совпадают, обратитесь к администратору сервера."
+ "Пожалуйста, попробуйте ещё раз и убедитесь, что Вы правильно ввели 2-значный код. Если цифры по-прежнему не совпадают, обратитесь к администратору сервера."
"Цифры не совпадают"
"Не удалось установить безопасное соединение с новым устройством. Не беспокойтесь, существующие устройства в безопасности."
"Что теперь?"
@@ -34,13 +34,15 @@
"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"
"Если это не помогло, войдите вручную"
"Соединение не защищено"
+ "Вам нужно будет ввести две цифры, показанные на этом устройстве."
+ "Введите показанный номер на своем другом устройстве"
"Вход на другом устройстве был отменен."
"Запрос на вход отменен"
"Вход в систему был отклонен на другом устройстве."
"Вход отклонен"
- "На этом все."
+ "На этом всё."
"Вход уже выполнен на другом устройстве"
- "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Срок действия входа истек. Пожалуйста, попробуйте ещё раз."
"Вход в систему не был выполнен вовремя"
"Другое устройство не поддерживает вход в %s с помощью QR-кода.
@@ -53,5 +55,6 @@
"Неверный QR-код"
"Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."
"Разрешите доступ к камере для сканирования QR-кода"
- "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."
+ "Произошла непредвиденная ошибка. Пожалуйста, попробуйте ещё раз."
+ "Ожидание другого устройства"
diff --git a/features/linknewdevice/impl/src/main/res/values-sk/translations.xml b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml
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 ed08ee1a04..1d436b5f4a 100644
--- a/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml
@@ -34,6 +34,8 @@
"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."
@@ -54,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 3aa047125a..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
@@ -34,6 +34,8 @@
"如果遇到相同的問題,請嘗試使用其他 wifi 網路或您的行動數據"
"若無法運作,請手動登入"
"連線不安全"
+ "系統會要求您輸入此裝置上顯示的兩位數字。"
+ "在您的其他裝置上輸入以下數字"
"已在其他裝置上取消登入。"
"已取消登入請求"
"其他裝置拒絕登入。"
@@ -54,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 1bd74d5104..f11f60dd61 100644
--- a/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
+++ b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml
@@ -9,7 +9,7 @@
"输入两位数字的代码"
"这将验证你与其它设备的连接是否安全。"
"请输入另一台设备上显示的数字"
- "账户提供方不支持 %1$s."
+ "账户提供者不支持 %1$s."
"不支持 %1$s."
"你的账户提供者不支持使用二维码登录到新设备。"
"二维码不受支持"
@@ -34,6 +34,8 @@
"如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi"
"如果不起作用,请手动登录"
"连接不安全"
+ "你将被要求输入此设备上显示的两位数字。"
+ "在你的其它设备上输入以下数字"
"登录被另一台设备取消"
"登录请求已取消"
"另一设备上的登录请求已被拒绝。"
@@ -46,7 +48,7 @@
尝试手动或使用另一个设备扫描二维码."
"二维码不受支持"
- "账户提供方不支持 %1$s."
+ "账户提供者不支持 %1$s."
"不支持 %1$s."
"使用其它设备上显示的二维码。"
"重试"
@@ -54,4 +56,5 @@
"你需要授予 %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..a198bc27d9 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,7 +11,9 @@ 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.features.lockscreen.test.FakeDeviceUnlockEntryPoint
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
@@ -37,6 +39,8 @@ class DefaultLinkNewDeviceEntryPointTest {
sessionCoroutineScope = backgroundScope,
linkNewMobileHandler = LinkNewMobileHandler(client),
linkNewDesktopHandler = LinkNewDesktopHandler(client),
+ sessionEnterpriseService = FakeSessionEnterpriseService(),
+ deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
)
}
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..cdb1cb6860 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.linknewdevice.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -18,42 +20,36 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class DesktopNoticeViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class DesktopNoticeViewTest : RobolectricTest() {
@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 +57,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..74619c1b71 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,54 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ErrorViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class ErrorViewTest : RobolectricTest() {
@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/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
index fe2e11e95c..b3602c81dc 100644
--- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt
@@ -88,6 +88,7 @@ class EnterNumberPresenterTest {
navigator = navigator,
linkNewMobileHandler = linkNewMobileHandler,
).test {
+ skipItems(1)
val initialState = awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender)
@@ -96,7 +97,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue)
- skipItems(1)
+ skipItems(2)
val finalState = awaitItem()
assertThat(finalState.sendingCode.isLoading()).isTrue()
advanceUntilIdle()
@@ -130,6 +131,7 @@ class EnterNumberPresenterTest {
LinkMobileStep.QrScanned(checkCodeSender)
)
runCurrent()
+ skipItems(1)
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue)
@@ -163,6 +165,7 @@ class EnterNumberPresenterTest {
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
).test {
+ skipItems(1)
val initialState = awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender)
@@ -171,7 +174,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue)
- skipItems(1)
+ skipItems(2)
val loadingState = awaitItem()
assertThat(loadingState.sendingCode.isLoading()).isTrue()
expectNoEvents()
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..0d6da84241 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,14 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -20,65 +22,59 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class EnterNumberViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class EnterNumberViewTest : RobolectricTest() {
@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..6715959e0e 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,37 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ShowQrCodeViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class ShowQrCodeViewTest : RobolectricTest() {
@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/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
index 88a68786f3..d2216c2737 100644
--- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
+++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt
@@ -75,15 +75,48 @@ class LinkNewDeviceRootPresenterTest {
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
)
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
createPresenter(
matrixClient = matrixClient,
+ linkNewMobileHandler = linkNewMobileHandler,
).test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSupported.dataOrNull()).isTrue()
- initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
+ linkNewMobileHandler.createAndStartNewHandler()
+ skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.qrCodeData.isLoading()).isTrue()
+ skipItems(1)
+ }
+ }
+
+ @Test
+ fun `present - close dialog resets qrCodeData`() = runTest {
+ val fakeLinkMobileHandler = FakeLinkMobileHandler(startResult = {})
+ val matrixClient = FakeMatrixClient(
+ canLinkNewDeviceResult = { Result.success(true) },
+ sessionCoroutineScope = backgroundScope,
+ createLinkMobileHandlerResult = { Result.success(fakeLinkMobileHandler) }
+ )
+ val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
+ createPresenter(
+ matrixClient = matrixClient,
+ linkNewMobileHandler = linkNewMobileHandler,
+ ).test {
+ skipItems(1)
+ linkNewMobileHandler.onTooManyRotation()
+ var errorState = awaitItem()
+ while (!errorState.qrCodeData.isFailure()) {
+ errorState = awaitItem()
+ }
+ assertThat(errorState.qrCodeData.isFailure()).isTrue()
+ errorState.eventSink(LinkNewDeviceRootEvent.CloseDialog)
+ var resetState = awaitItem()
+ while (!resetState.qrCodeData.isUninitialized()) {
+ resetState = awaitItem()
+ }
+ assertThat(resetState.qrCodeData.isUninitialized()).isTrue()
}
}
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..fd75a4d560 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,97 +5,97 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
+import io.element.android.tests.testutils.EnsureNeverCalledWithParam
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.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
-import org.junit.Rule
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class LinkNewDeviceRootViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class LinkNewDeviceRootViewTest : RobolectricTest() {
@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(
+ ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
+ setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
- onLinkDesktopDeviceClick = callback,
+ onUnlockDevice = 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`() {
- val eventRecorder = EventsRecorder()
- rule.setLinkNewDeviceRootView(
- state = aLinkNewDeviceRootState(
- isSupported = AsyncData.Success(true),
- eventSink = eventRecorder,
+ fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
+ val eventRecorder = EventsRecorder(expectEvents = false)
+ ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
+ setLinkNewDeviceRootView(
+ state = aLinkNewDeviceRootState(
+ isSupported = AsyncData.Success(true),
+ eventSink = eventRecorder,
+ ),
+ onUnlockDevice = callback,
)
- )
- rule.clickOn(R.string.screen_link_new_device_root_mobile_device)
- eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
+ clickOn(R.string.screen_link_new_device_root_mobile_device)
+ }
}
@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(),
+ onUnlockDevice: (type: LinkDeviceType) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
LinkNewDeviceRootView(
state = state,
onBackClick = onBackClick,
- onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
+ onUnlockDevice = onUnlockDevice,
)
}
}
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..5daf78660a 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,12 +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.scan
import androidx.activity.ComponentActivity
-import androidx.compose.ui.test.junit4.AndroidComposeTestRule
-import androidx.compose.ui.test.junit4.createAndroidComposeRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
+import 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 io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
@@ -19,52 +23,48 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ScanQrCodeViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class ScanQrCodeViewTest : RobolectricTest() {
@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(),
) {
setContent {
- ScanQrCodeView(
- state = state,
- onBackClick = onBackClick,
- )
+ CompositionLocalProvider(LocalInspectionMode provides true) {
+ ScanQrCodeView(
+ state = state,
+ onBackClick = onBackClick,
+ )
+ }
}
}
}
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/RenderingMapsNotSupportedDialog.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/RenderingMapsNotSupportedDialog.kt
new file mode 100644
index 0000000000..a4d65763ab
--- /dev/null
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/RenderingMapsNotSupportedDialog.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.location.api
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+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.ui.strings.CommonStrings
+
+@Composable
+fun RenderingMapsNotSupportedDialog(onSubmit: () -> Unit) {
+ ErrorDialog(
+ title = stringResource(CommonStrings.vulkan_not_supported_dialog_title_android),
+ content = stringResource(CommonStrings.vulkan_not_supported_dialog_content_android),
+ onSubmit = onSubmit,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun RenderingMapsNotSupportedDialogPreview() = ElementPreview {
+ RenderingMapsNotSupportedDialog(onSubmit = {})
+}
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 a61cbe1c24..c16b1ae560 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
@@ -8,6 +8,7 @@
package io.element.android.features.location.api
+import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -24,13 +25,17 @@ 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.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.core.graphics.createBitmap
import coil3.Extras
+import coil3.asImage
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
+import coil3.request.SuccessResult
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
@@ -39,6 +44,7 @@ import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.utils.CommonDrawables
/**
* Shows a static map image downloaded via a third party service's static maps API.
@@ -128,32 +134,46 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
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,
+ val (painter, state, contentScale) = if (LocalInspectionMode.current) {
+ val painter = painterResource(CommonDrawables.sample_map)
+ val state = AsyncImagePainter.State.Success(
+ painter = painter,
+ result = SuccessResult(
+ image = createBitmap(1, 1, Bitmap.Config.ALPHA_8).asImage(),
+ request = ImageRequest.Builder(context).build()
+ )
+ )
+ Triple(painter, state, ContentScale.Crop)
+ } else {
+ 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()
- }
- )
+ .size(width = constraints.maxWidth, height = constraints.maxHeight)
+ .apply {
+ extras.set(Extras.Key("retry_hash"), retryHash).build()
+ }
+ .build()
+ }
+ )
+
+ val state by painter.state.collectAsState()
+ Triple(painter, state, ContentScale.Fit)
+ }
- val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Success -> {
Image(
@@ -162,7 +182,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
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. We apply ContentScale.Fit to handle this.
- contentScale = ContentScale.Fit,
+ contentScale = contentScale,
)
LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this))
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
index d119dc7eb2..054805d1ab 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt
@@ -25,8 +25,18 @@ internal class MapTilerTileServerStyleUriBuilder(
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
- override fun build(darkMode: Boolean): String {
- val mapId = if (darkMode) darkMapId else lightMapId
- return "$baseUrl/$mapId/style.json?key=$apiKey"
+ override fun build(
+ customMapStyleUrl: String?,
+ darkMode: Boolean,
+ ): String {
+ return buildString {
+ if (customMapStyleUrl.isNullOrBlank()) {
+ val mapId = if (darkMode) darkMapId else lightMapId
+ append("$baseUrl/$mapId/style.json")
+ } else {
+ append(customMapStyleUrl)
+ }
+ append("?key=$apiKey")
+ }
}
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
index 17b620eec9..762d580f8e 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt
@@ -19,21 +19,25 @@ import io.element.android.compound.theme.ElementTheme
*/
interface TileServerStyleUriBuilder {
fun build(
+ customMapStyleUrl: String?,
darkMode: Boolean,
): String
}
-fun TileServerStyleUriBuilder(): TileServerStyleUriBuilder = MapTilerTileServerStyleUriBuilder()
-
/**
* Provides and remembers a style URI for a MapLibre compatible tile server.
*
* Used for rendering dynamic maps.
*/
@Composable
-fun rememberTileStyleUrl(): String {
+fun rememberTileStyleUrl(
+ customMapStyleUrl: String?,
+): String {
val darkMode = !ElementTheme.isLightTheme
- return remember(darkMode) {
- TileServerStyleUriBuilder().build(darkMode)
+ return remember(darkMode, customMapStyleUrl) {
+ MapTilerTileServerStyleUriBuilder().build(
+ customMapStyleUrl = customMapStyleUrl,
+ darkMode = darkMode,
+ )
}
}
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/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
index d5c6521258..2e07204594 100644
--- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
+++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt
@@ -22,14 +22,40 @@ class MapTilerTileServerStyleUriBuilderTest {
@Test
fun `light map uri`() {
assertThat(
- builder.build(darkMode = false)
+ builder.build(
+ customMapStyleUrl = null,
+ darkMode = false,
+ )
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
}
@Test
fun `dark map uri`() {
assertThat(
- builder.build(darkMode = true)
+ builder.build(
+ customMapStyleUrl = null,
+ darkMode = true,
+ )
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
}
+
+ @Test
+ fun `custom map uri light`() {
+ assertThat(
+ builder.build(
+ customMapStyleUrl = "https://custom.url/style.json",
+ darkMode = false,
+ )
+ ).isEqualTo("https://custom.url/style.json?key=anApiKey")
+ }
+
+ @Test
+ fun `custom map uri dark`() {
+ assertThat(
+ builder.build(
+ customMapStyleUrl = "https://custom.url/style.json",
+ darkMode = true,
+ )
+ ).isEqualTo("https://custom.url/style.json?key=anApiKey")
+ }
}
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..e56a13e644 100644
--- a/features/location/impl/src/main/AndroidManifest.xml
+++ b/features/location/impl/src/main/AndroidManifest.xml
@@ -5,8 +5,26 @@
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
-
+
+
+
+
+
+
+
+
+
+
+
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..72add1ea14 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
@@ -14,15 +14,19 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi
sealed interface LocationConstraintsCheck {
data object Success : LocationConstraintsCheck
data object PermissionRationale : LocationConstraintsCheck
+ data object PermissionShouldBeRequested : 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
@@ -31,6 +35,7 @@ fun checkLocationConstraints(
}
}
permissionsState.shouldShowRationale -> LocationConstraintsCheck.PermissionRationale
+ !permissionsState.permissionsAlreadyRequested -> LocationConstraintsCheck.PermissionShouldBeRequested
else -> LocationConstraintsCheck.PermissionDenied
}
}
@@ -38,8 +43,10 @@ fun checkLocationConstraints(
fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState {
return when (this) {
LocationConstraintsCheck.Success -> LocationConstraintsDialogState.None
+ LocationConstraintsCheck.PermissionShouldBeRequested -> LocationConstraintsDialogState.None
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/permissions/DefaultPermissionsPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt
index 1aa2e1269d..5aa7ae6f63 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt
@@ -9,6 +9,10 @@
package io.element.android.features.location.impl.common.permissions
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@@ -32,11 +36,16 @@ class DefaultPermissionsPresenter(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
override fun present(): PermissionsState {
- val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions)
+ var permissionsRequested by remember { mutableStateOf(false) }
+ val multiplePermissionsState = rememberMultiplePermissionsState(permissions = permissions) {
+ permissionsRequested = true
+ }
fun handleEvent(event: PermissionsEvents) {
when (event) {
- PermissionsEvents.RequestPermissions -> multiplePermissionsState.launchMultiplePermissionRequest()
+ PermissionsEvents.RequestPermissions -> {
+ multiplePermissionsState.launchMultiplePermissionRequest()
+ }
}
}
@@ -47,6 +56,7 @@ class DefaultPermissionsPresenter(
else -> PermissionsState.Permissions.NoneGranted
},
shouldShowRationale = multiplePermissionsState.shouldShowRationale,
+ permissionsAlreadyRequested = permissionsRequested,
eventSink = ::handleEvent,
)
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt
index 91191280d0..25ceb3ba29 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsState.kt
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.common.permissions
data class PermissionsState(
val permissions: Permissions,
val shouldShowRationale: Boolean,
+ val permissionsAlreadyRequested: Boolean,
val eventSink: (PermissionsEvents) -> Unit,
) {
sealed interface Permissions {
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/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt
index 762a719cf4..e30dfd53de 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt
@@ -14,6 +14,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.Location
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.serialization.json.JsonPrimitive
import org.maplibre.compose.expressions.dsl.and
import org.maplibre.compose.expressions.dsl.asString
@@ -66,7 +67,7 @@ data class LocationMarkerData(
*/
@Composable
fun LocationPinMarkers(
- markers: List,
+ markers: ImmutableList,
onMarkerClick: ((LocationMarkerData) -> Unit)? = null,
onClusterClick: ((Position) -> Unit)? = null,
) {
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 83db9a9c61..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(
@@ -99,11 +103,24 @@ fun LocationShareRow(
)
}
}
+ 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 13c30c28eb..5f09bcedb2 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
@@ -37,6 +37,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
@@ -44,9 +45,11 @@ import androidx.compose.ui.unit.dp
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.architecture.AsyncData
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 io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
@@ -63,6 +66,7 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning
*
+ * @param customMapStyleUrl Optional custom style URL for the map
* @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map
@@ -79,6 +83,7 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapBottomSheetScaffold(
+ customMapStyleUrl: AsyncData,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
@@ -130,13 +135,22 @@ fun MapBottomSheetScaffold(
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
Box {
- MaplibreMap(
- options = mapOptions,
- baseStyle = BaseStyle.Uri(rememberTileStyleUrl()),
- modifier = Modifier.fillMaxSize(),
- cameraState = cameraState,
- content = mapContent,
- )
+ when (customMapStyleUrl) {
+ is AsyncData.Success -> {
+ MaplibreMap(
+ options = mapOptions,
+ baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapStyleUrl.data)),
+ modifier = Modifier.fillMaxSize(),
+ cameraState = cameraState,
+ content = mapContent,
+ )
+ }
+ else -> {
+ Box(modifier = Modifier.fillMaxSize()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ }
+ }
overlayContent(sheetPadding)
}
}
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..074404a4cc 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
@@ -7,75 +7,35 @@
package io.element.android.features.location.impl.common.ui
-import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
-import io.element.android.features.location.impl.common.MapDefaults
import org.maplibre.compose.camera.CameraState
-import org.maplibre.compose.location.DesiredAccuracy
+import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
-import org.maplibre.compose.location.LocationTrackingEffect
-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
@Composable
fun UserLocationPuck(
cameraState: CameraState,
- locationState: UserLocationState,
- trackUserLocation: Boolean,
+ location: Location?,
) {
- LocationTrackingEffect(
- locationState = locationState,
- enabled = trackUserLocation,
- ) {
- val finalPosition = cameraState.position.copy(
- target = currentLocation.position,
- bearing = currentLocation.bearing ?: cameraState.position.bearing,
- zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
+ LocationPuck(
+ idPrefix = "user-location",
+ location = location,
+ cameraState = cameraState,
+ accuracyThreshold = Float.POSITIVE_INFINITY,
+ showBearingAccuracy = false,
+ showBearing = false,
+ sizes = LocationPuckSizes(
+ dotRadius = 8.dp,
+ dotStrokeWidth = 2.dp,
+ ),
+ colors = LocationPuckColors(
+ dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
+ dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
+ dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
)
- cameraState.animateTo(finalPosition)
- }
- val location = locationState.location
- if (location != null) {
- LocationPuck(
- idPrefix = "user-location",
- locationState = locationState,
- cameraState = cameraState,
- accuracyThreshold = Float.POSITIVE_INFINITY,
- showBearingAccuracy = false,
- showBearing = false,
- sizes = LocationPuckSizes(
- dotRadius = 8.dp,
- dotStrokeWidth = 2.dp,
- ),
- colors = LocationPuckColors(
- dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
- dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
- dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
- )
- )
- }
-}
-
-@SuppressLint("MissingPermission")
-@Composable
-fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState {
- val isPreview = LocalInspectionMode.current
- val locationProvider = if (isPreview || !hasLocationPermission) {
- rememberNullLocationProvider()
- } else {
- rememberAndroidLocationProvider(
- updateInterval = 1.minutes,
- desiredAccuracy = DesiredAccuracy.Balanced,
- minDistanceMeters = 50f,
- )
- }
- return rememberUserLocationState(locationProvider)
+ )
}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt
new file mode 100644
index 0000000000..01a2b92d4a
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.location.impl.common.userlocation
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.di.RoomScope
+import org.maplibre.compose.location.DesiredAccuracy
+import org.maplibre.compose.location.rememberNullLocationProvider
+import org.maplibre.spatialk.units.extensions.meters
+import kotlin.time.Duration.Companion.seconds
+
+@ContributesBinding(RoomScope::class)
+class DefaultUserLocationStateFactory : UserLocationState.Factory {
+ @Composable
+ override fun create(hasLocationPermission: Boolean): UserLocationState {
+ val locationProvider = if (!hasLocationPermission) {
+ rememberNullLocationProvider()
+ } else {
+ @SuppressLint("MissingPermission")
+ rememberPlatformLocationProvider(
+ updateInterval = 5.seconds,
+ desiredAccuracy = DesiredAccuracy.High,
+ minDistance = 5.meters,
+ )
+ }
+ val location by locationProvider.location.collectAsState()
+ return UserLocationState(location)
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt
new file mode 100644
index 0000000000..9777e14af5
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.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.location.impl.common.userlocation
+
+import io.element.android.features.location.api.Location
+import org.maplibre.compose.location.PositionWithAccuracy
+import org.maplibre.spatialk.geojson.Position
+import org.maplibre.spatialk.units.extensions.meters
+import kotlin.time.TimeSource
+import org.maplibre.compose.location.Location as MapLibreLocation
+
+fun Location.asMapLibreLocation(): MapLibreLocation {
+ return MapLibreLocation(
+ position = PositionWithAccuracy(
+ value = Position(latitude = lat, longitude = lon),
+ accuracy = accuracy?.toDouble()?.meters
+ ),
+ // Not relevant as not used
+ timestamp = TimeSource.Monotonic.markNow(),
+ )
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.kt
new file mode 100644
index 0000000000..54e6d19cf6
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/PlatformLocationProvider.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.common.userlocation
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageManager
+import android.location.LocationManager
+import android.os.Handler
+import android.os.HandlerThread
+import androidx.annotation.RequiresPermission
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.location.LocationListenerCompat
+import androidx.core.location.LocationManagerCompat
+import androidx.core.location.LocationRequestCompat
+import androidx.core.os.ExecutorCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.stateIn
+import org.maplibre.compose.location.DesiredAccuracy
+import org.maplibre.compose.location.Location
+import org.maplibre.compose.location.LocationProvider
+import org.maplibre.compose.location.PermissionException
+import org.maplibre.compose.location.asMapLibreLocation
+import org.maplibre.spatialk.units.Length
+import org.maplibre.spatialk.units.extensions.inMeters
+import kotlin.time.Duration
+
+@SuppressLint("InlinedApi")
+class PlatformLocationProvider(
+ context: Context,
+ private val updateInterval: Duration,
+ private val minDistance: Length,
+ private val desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High,
+ coroutineScope: CoroutineScope,
+ sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000),
+) : LocationProvider {
+ override val location: StateFlow
+
+ init {
+ if (!handlerThread.isAlive) handlerThread.start()
+ if (
+ context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED &&
+ context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED
+ ) {
+ throw PermissionException()
+ }
+ val locationManager = context.getSystemService(LocationManager::class.java)
+ val provider = PROVIDERS_BY_PRIORITY.firstOrNull { LocationManagerCompat.hasProvider(locationManager, it) }
+ val locationFlow = if (provider != null) {
+ createProviderFlow(locationManager, provider)
+ } else {
+ emptyFlow()
+ }
+ location = locationFlow.stateIn(coroutineScope, sharingStarted, null)
+ }
+
+ @RequiresPermission(
+ anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]
+ )
+ private fun createProviderFlow(locationManager: LocationManager, provider: String) = callbackFlow {
+ send(locationManager.getLastKnownLocation(provider)?.asMapLibreLocation())
+ val listener = LocationListenerCompat { trySend(it.asMapLibreLocation()) }
+ val request = LocationRequestCompat.Builder(updateInterval.inWholeMilliseconds)
+ .setQuality(desiredAccuracy.toLocationRequestQuality())
+ .setMinUpdateDistanceMeters(minDistance.inMeters.toFloat())
+ .build()
+ LocationManagerCompat.requestLocationUpdates(
+ locationManager,
+ provider,
+ request,
+ ExecutorCompat.create(Handler(handlerThread.looper)),
+ listener,
+ )
+ awaitClose { LocationManagerCompat.removeUpdates(locationManager, listener) }
+ }
+
+ private companion object {
+ private val PROVIDERS_BY_PRIORITY = listOf(
+ LocationManager.FUSED_PROVIDER,
+ LocationManager.GPS_PROVIDER,
+ LocationManager.NETWORK_PROVIDER,
+ )
+ private val handlerThread by lazy { HandlerThread("PlatformLocationProvider") }
+ }
+}
+
+private fun DesiredAccuracy.toLocationRequestQuality(): Int = when (this) {
+ DesiredAccuracy.Highest, DesiredAccuracy.High -> LocationRequestCompat.QUALITY_HIGH_ACCURACY
+ DesiredAccuracy.Balanced -> LocationRequestCompat.QUALITY_BALANCED_POWER_ACCURACY
+ DesiredAccuracy.Low, DesiredAccuracy.Lowest -> LocationRequestCompat.QUALITY_LOW_POWER
+}
+
+@Composable
+@RequiresPermission(
+ anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]
+)
+fun rememberPlatformLocationProvider(
+ updateInterval: Duration,
+ minDistance: Length,
+ desiredAccuracy: DesiredAccuracy = DesiredAccuracy.High,
+ context: Context = LocalContext.current,
+ coroutineScope: CoroutineScope = rememberCoroutineScope(),
+ sharingStarted: SharingStarted = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1000),
+): PlatformLocationProvider {
+ return remember(context, updateInterval, minDistance, desiredAccuracy, coroutineScope, sharingStarted) {
+ PlatformLocationProvider(
+ context = context,
+ updateInterval = updateInterval,
+ minDistance = minDistance,
+ desiredAccuracy = desiredAccuracy,
+ coroutineScope = coroutineScope,
+ sharingStarted = sharingStarted,
+ )
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt
new file mode 100644
index 0000000000..d9dc2037a6
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt
@@ -0,0 +1,18 @@
+/*
+ * 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.userlocation
+
+import androidx.compose.runtime.Composable
+import org.maplibre.compose.location.Location
+
+data class UserLocationState(val location: Location?) {
+ fun interface Factory {
+ @Composable
+ fun create(hasLocationPermission: Boolean): UserLocationState
+ }
+}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt
new file mode 100644
index 0000000000..ca6c10b7fa
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.userlocation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import io.element.android.features.location.impl.common.MapDefaults
+import kotlinx.coroutines.flow.distinctUntilChanged
+import org.maplibre.compose.camera.CameraState
+import org.maplibre.compose.location.Location
+import kotlin.math.abs
+
+/**
+ * Drop-in replacement for the library's LocationTrackingEffect.
+ * TODO remove once https://github.com/maplibre/maplibre-compose/issues/808 is fixed
+ */
+@Composable
+internal fun UserLocationTrackingEffect(
+ locationState: UserLocationState,
+ enabled: Boolean = true,
+ precision: Double = 0.00001,
+ onLocationChange: suspend (Location?) -> Unit,
+) {
+ val latestOnLocationChange by rememberUpdatedState(onLocationChange)
+ val latestLocationState by rememberUpdatedState(locationState)
+ LaunchedEffect(enabled) {
+ if (!enabled) return@LaunchedEffect
+ val locationStateFlow = snapshotFlow { latestLocationState.location }
+ locationStateFlow
+ .distinctUntilChanged { oldLocation, newLocation ->
+ if (oldLocation != null && newLocation != null) {
+ when {
+ abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false
+ abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false
+ else -> true
+ }
+ } else {
+ false
+ }
+ }
+ .collect { location ->
+ latestOnLocationChange(location)
+ }
+ }
+}
+
+@Composable
+internal fun UserLocationTrackingEffect(
+ cameraState: CameraState,
+ locationState: UserLocationState,
+ enabled: Boolean = true,
+ precision: Double = 0.00001,
+) {
+ UserLocationTrackingEffect(
+ locationState = locationState,
+ enabled = enabled,
+ precision = precision
+ ) { location ->
+ val target = location?.position?.value ?: cameraState.position.target
+ cameraState.animateTo(
+ cameraState.position.copy(
+ target = target,
+ // Force pointing to NORTH
+ bearing = 0.0,
+ zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
+ )
+ )
+ }
+}
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..c24d3f9cc2
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/DefaultActiveLiveLocationShareManager.kt
@@ -0,0 +1,232 @@
+/*
+ * 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 onUnrecoverableError() {
+ Timber.d("ActiveLiveLocationShareManager unrecoverable error, stopping all shares")
+ localSharingRoomIds.value.toList().forEach { stopShare(it) }
+ }
+
+ 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..697a074093
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationReceiver.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.location.impl.live.service
+
+import io.element.android.features.location.api.Location
+
+interface LiveLocationReceiver {
+ suspend fun onLocationUpdate(location: Location)
+ suspend fun onUnrecoverableError()
+}
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..63618baa3c
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingCoordinator.kt
@@ -0,0 +1,109 @@
+/*
+ * 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 dispatchUnrecoverableError() {
+ Timber.d("LiveLocationSharingCoordinator dispatching unrecoverable error")
+ receivers.forEach { (sessionId, receiver) ->
+ runCatchingExceptions {
+ receiver.onUnrecoverableError()
+ }.onFailure {
+ Timber.e(it, "Failed to dispatch unrecoverable error for session $sessionId")
+ }
+ }
+ }
+
+ 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..21a0a081d8
--- /dev/null
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.common.userlocation.PlatformLocationProvider
+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.emptyFlow
+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 kotlinx.coroutines.launch
+import org.maplibre.compose.location.DesiredAccuracy
+import org.maplibre.compose.location.PermissionException
+import org.maplibre.spatialk.units.extensions.inMeters
+import org.maplibre.spatialk.units.extensions.meters
+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")
+ bindings().inject(this)
+ runCatchingExceptions {
+ 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")
+ appCoroutineScope.launch { coordinator.dispatchUnrecoverableError() }
+ stopSelf()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private fun startLocationUpdatesListener() {
+ Timber.d("LiveLocationSharingService listening to location updates")
+ appPreferencesStore.getLiveLocationMinimumDistanceInMetersUpdateFlow()
+ .flatMapLatest { minDistanceMeters ->
+ try {
+ PlatformLocationProvider(
+ context = applicationContext,
+ updateInterval = UPDATE_INTERVAL_IN_SECOND.seconds,
+ minDistance = minDistanceMeters.meters,
+ desiredAccuracy = DesiredAccuracy.Balanced,
+ coroutineScope = coroutineScope
+ ).location
+ } catch (exception: PermissionException) {
+ Timber.e(exception, "Failed to create PlatformLocationProvider")
+ coordinator.dispatchUnrecoverableError()
+ emptyFlow()
+ }
+ }
+ .filterNotNull()
+ .map { location ->
+ ApiLocation(
+ lat = location.position.value.latitude,
+ lon = location.position.value.longitude,
+ accuracy = location.position.accuracy?.inMeters?.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..a3f3cac432 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
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -21,26 +22,32 @@ 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.common.userlocation.UserLocationState
+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.AsyncData
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 +67,11 @@ 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,
+ private val userLocationStateFactory: UserLocationState.Factory,
) : Presenter {
@AssistedFactory
fun interface Factory {
@@ -75,23 +84,73 @@ 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)
}
+ // true when trying to initiate the live location share
+ var pendingLiveLocationShare by remember { mutableStateOf(false) }
+ val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
+ val customMapStyleUrl by produceState(AsyncData.Loading()) {
+ // Ignore errors
+ value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
+ }
+ val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
+ perms.sendLiveLocationPermissions()
+ }
val scope = rememberCoroutineScope()
fun checkLocationConstraints() {
- val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
- dialogState = Constraints(locationConstraints.toDialogState())
+ val locationConstraints = checkLocationConstraints(
+ permissionsState = permissionsState,
+ locationActions = locationActions,
+ // No need to check SendLiveLocationPermissions here
+ sendLiveLocationPermissions = SendLiveLocationPermissions.GRANTED
+ )
+ if (locationConstraints is LocationConstraintsCheck.PermissionShouldBeRequested) {
+ permissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ }
trackUserPosition = locationConstraints is LocationConstraintsCheck.Success
+ dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
}
- LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() }
+ suspend fun checkLiveLocationConstraints() {
+ val locationConstraints = checkLocationConstraints(
+ permissionsState = permissionsState,
+ locationActions = locationActions,
+ sendLiveLocationPermissions = sendLiveLocationPermissions,
+ )
+ when (locationConstraints) {
+ LocationConstraintsCheck.Success -> {
+ val hasAcceptedDisclaimer = liveLocationStore.hasAcceptedLiveLocationDisclaimer()
+ dialogState = if (!hasAcceptedDisclaimer) {
+ ShareLocationState.Dialog.LiveLocationDisclaimer
+ } else {
+ val durations = LIVE_LOCATION_DURATIONS.map {
+ LiveLocationDuration(duration = it, formatted = durationFormatter.format(it))
+ }
+ ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
+ }
+ }
+ else -> {
+ if (locationConstraints is LocationConstraintsCheck.PermissionShouldBeRequested) {
+ permissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ }
+ dialogState = ShareLocationState.Dialog.Constraints(locationConstraints.toDialogState())
+ }
+ }
+ }
+
+ val userLocationState = userLocationStateFactory.create(permissionsState.isAnyGranted)
+
+ LaunchedEffect(permissionsState) {
+ if (pendingLiveLocationShare) {
+ checkLiveLocationConstraints()
+ } else {
+ checkLocationConstraints()
+ }
+ }
fun handleEvent(event: ShareLocationEvent) {
when (event) {
@@ -100,7 +159,10 @@ class ShareLocationPresenter(
}
ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints()
ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false
- ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
+ ShareLocationEvent.DismissDialog -> {
+ pendingLiveLocationShare = false
+ dialogState = ShareLocationState.Dialog.None
+ }
ShareLocationEvent.OpenAppSettings -> {
locationActions.openAppSettings()
dialogState = ShareLocationState.Dialog.None
@@ -109,20 +171,25 @@ 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 {
+ pendingLiveLocationShare = true
+ checkLiveLocationConstraints()
+ }
+ ShareLocationEvent.AcceptLiveLocationDisclaimer -> scope.launch {
+ liveLocationStore.setAcceptedLiveLocationDisclaimer()
+ .onSuccess {
+ checkLiveLocationConstraints()
}
- ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList())
- } else {
- Constraints(constraintsResult.toDialogState())
- }
}
is ShareLocationEvent.StartLiveLocationShare -> scope.launch {
+ pendingLiveLocationShare = false
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
@@ -132,12 +199,14 @@ class ShareLocationPresenter(
}
return ShareLocationState(
+ customMapStyleUrl = customMapStyleUrl,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
- hasLocationPermission = permissionsState.isAnyGranted,
- canShareLiveLocation = isLiveLocationSharingEnabled,
+ userLocationState = userLocationState,
+ canShareLiveLocation = timelineMode.canShareLiveLocation(),
appName = appName,
+ startLiveLocationAction = startLiveLocationAction.value,
eventSink = ::handleEvent,
)
}
@@ -174,4 +243,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..fb1b803435 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,21 +9,27 @@
package io.element.android.features.location.impl.share
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.features.location.impl.common.userlocation.UserLocationState
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
+ val customMapStyleUrl: AsyncData,
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
- val hasLocationPermission: Boolean,
+ val userLocationState: UserLocationState,
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..ed8d46afd3 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,9 @@ 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.features.location.impl.common.userlocation.UserLocationState
+import io.element.android.libraries.architecture.AsyncAction
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf
@@ -24,32 +27,36 @@ class ShareLocationStateProvider : PreviewParameterProvider
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false,
- hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
trackUserPosition = false,
- hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
trackUserPosition = false,
- hasLocationPermission = false,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
trackUserPosition = false,
- hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = false,
- hasLocationPermission = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.None,
trackUserPosition = true,
- hasLocationPermission = true,
+ ),
+ aShareLocationState(
+ dialogState = ShareLocationState.Dialog.None,
+ trackUserPosition = true,
+ canShareLiveLocation = true,
+ ),
+ aShareLocationState(
+ dialogState = ShareLocationState.Dialog.LiveLocationDisclaimer,
+ trackUserPosition = true,
+ canShareLiveLocation = true,
),
aShareLocationState(
dialogState = ShareLocationState.Dialog.LiveLocationDurations(
@@ -60,28 +67,34 @@ class ShareLocationStateProvider : PreviewParameterProvider
)
),
trackUserPosition = true,
- hasLocationPermission = true,
canShareLiveLocation = true,
),
+ aShareLocationState(
+ customMapStyleUrl = AsyncData.Loading(),
+ ),
)
}
fun aShareLocationState(
+ customMapStyleUrl: AsyncData = AsyncData.Success(null),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
- hasLocationPermission: Boolean = false,
+ userLocationState: UserLocationState = UserLocationState(null),
canShareLiveLocation: Boolean = false,
appName: String = APP_NAME,
+ startLiveLocationAction: AsyncAction = AsyncAction.Uninitialized,
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
+ customMapStyleUrl = customMapStyleUrl,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
- hasLocationPermission = hasLocationPermission,
+ userLocationState = userLocationState,
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..4760b3597c 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
@@ -26,10 +26,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
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
@@ -43,12 +43,17 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
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.common.userlocation.UserLocationTrackingEffect
+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
@@ -64,7 +69,6 @@ import kotlinx.collections.immutable.ImmutableList
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
-import org.maplibre.compose.location.UserLocationState
import kotlin.time.Duration
@OptIn(ExperimentalMaterial3Api::class)
@@ -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) },
)
@@ -100,7 +108,6 @@ fun ShareLocationView(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
)
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
- val userLocationState = rememberUserLocationState(state.hasLocationPermission)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
@@ -109,6 +116,7 @@ fun ShareLocationView(
}
MapBottomSheetScaffold(
+ customMapStyleUrl = state.customMapStyleUrl,
cameraState = cameraState,
modifier = modifier,
scaffoldState = scaffoldState,
@@ -126,15 +134,18 @@ fun ShareLocationView(
BottomSheetContent(
cameraState = cameraState,
state = state,
- userLocationState = userLocationState,
navigateUp = navigateUp
)
},
mapContent = {
+ UserLocationTrackingEffect(
+ cameraState = cameraState,
+ locationState = state.userLocationState,
+ enabled = state.trackUserLocation,
+ )
UserLocationPuck(
cameraState = cameraState,
- locationState = userLocationState,
- trackUserLocation = state.trackUserLocation
+ location = state.userLocationState.location,
)
},
overlayContent = { sheetPadding ->
@@ -160,26 +171,62 @@ fun ShareLocationView(
.align(Alignment.TopEnd)
.padding(all = 16.dp),
)
+ StartLiveLocationActionView(state.startLiveLocationAction, navigateUp)
}
)
}
+@Composable
+private fun StartLiveLocationActionView(
+ action: AsyncAction,
+ onActionSuccess: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val updatedOnActionSuccess by rememberUpdatedState(onActionSuccess)
+ 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) { updatedOnActionSuccess() }
+ }
+ else -> Unit
+ }
+ }
+}
+
@Composable
private fun BottomSheetContent(
cameraState: CameraState,
state: ShareLocationState,
- userLocationState: UserLocationState,
navigateUp: () -> Unit,
) {
Spacer(Modifier.height(20.dp))
- val userLocation = userLocationState.location
+ val userLocation = state.userLocationState.location
if (state.trackUserLocation && userLocation != null) {
ShareCurrentLocationItem {
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(
- lat = userLocation.position.latitude,
- lon = userLocation.position.longitude
+ lat = userLocation.position.value.latitude,
+ lon = userLocation.position.value.longitude
),
isPinned = false
)
@@ -202,7 +249,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/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 43d3aa6d00..f187e91c53 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
@@ -10,19 +10,25 @@ package io.element.android.features.location.impl.show
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
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.api.live.isCurrentlySharing
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
@@ -30,6 +36,9 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.features.location.impl.common.userlocation.UserLocationState
+import io.element.android.features.location.impl.common.userlocation.asMapLibreLocation
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
@@ -37,6 +46,7 @@ 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.MatrixClient
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
@@ -45,19 +55,21 @@ 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(
+@AssistedInject class ShowLocationPresenter(
@Assisted private val mode: ShowLocationMode,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
+ private val client: MatrixClient,
private val joinedRoom: JoinedRoom,
+ private val liveLocationShareManager: ActiveLiveLocationShareManager,
+ private val userLocationStateFactory: UserLocationState.Factory,
) : Presenter {
- @AssistedFactory
- fun interface Factory {
+ @AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
}
@@ -65,6 +77,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 } }
@@ -72,10 +85,27 @@ class ShowLocationPresenter(
mutableStateOf(LocationConstraintsDialogState.None)
}
- LaunchedEffect(permissionsState.permissions) {
- if (permissionsState.isAnyGranted) {
- dialogState = LocationConstraintsDialogState.None
+ val customMapStyleUrl by produceState(AsyncData.Loading()) {
+ // Ignore errors
+ value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
+ }
+
+ fun checkLocationConstraints() {
+ val locationConstraints = checkLocationConstraints(
+ permissionsState = permissionsState,
+ locationActions = locationActions,
+ // No need to check SendLiveLocationPermissions here
+ sendLiveLocationPermissions = SendLiveLocationPermissions.GRANTED
+ )
+ if (locationConstraints is LocationConstraintsCheck.PermissionShouldBeRequested) {
+ permissionsState.eventSink(PermissionsEvents.RequestPermissions)
}
+ isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
+ dialogState = locationConstraints.toDialogState()
+ }
+
+ LaunchedEffect(permissionsState) {
+ checkLocationConstraints()
}
fun handleEvent(event: ShowLocationEvent) {
@@ -85,9 +115,7 @@ class ShowLocationPresenter(
}
is ShowLocationEvent.TrackMyLocation -> {
if (event.enabled) {
- val locationConstraints = checkLocationConstraints(permissionsState, locationActions)
- isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success
- dialogState = locationConstraints.toDialogState()
+ checkLocationConstraints()
} else {
isTrackMyLocation = false
}
@@ -102,6 +130,9 @@ class ShowLocationPresenter(
dialogState = LocationConstraintsDialogState.None
}
ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
+ ShowLocationEvent.StopLocationSharing -> coroutineScope.launch {
+ liveLocationShareManager.stopShare(joinedRoom.roomId)
+ }
}
}
@@ -127,6 +158,7 @@ class ShowLocationPresenter(
location = mode.location,
isLive = false,
assetType = mode.assetType,
+ isOwnUser = mode.senderId == joinedRoom.sessionId
)
)
}
@@ -137,53 +169,66 @@ class ShowLocationPresenter(
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,
- )
- }
- .toImmutableList()
+ 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 }
+ val updatedLocationShares by rememberUpdatedState(locationShares)
+ val focusedLocation by remember {
+ derivedStateOf {
+ when (mode) {
+ is ShowLocationMode.Static -> updatedLocationShares.firstOrNull()
+ is ShowLocationMode.Live -> updatedLocationShares.firstOrNull { it.userId == mode.senderId }
+ }
+ }
+ }
+ val isCurrentlySharing by liveLocationShareManager.isCurrentlySharing(roomId = joinedRoom.roomId).collectAsState()
+ val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing
+ val userLocationState = if (hideUserLocationPuck) {
+ // When sharing with this device, use the user LocationShareItem as source of data instead of the device.
+ val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.asMapLibreLocation() } }
+ UserLocationState(ownLocationShare)
+ } else {
+ userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
}
-
return ShowLocationState(
+ customMapStyleUrl = customMapStyleUrl,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
- hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
+ userLocationState = userLocationState,
isLive = mode is ShowLocationMode.Live,
appName = appName,
+ hideUserLocationPuck = hideUserLocationPuck,
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 b6a60f35db..e5ddf589b0 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
@@ -11,6 +11,8 @@ package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
+import io.element.android.features.location.impl.common.userlocation.UserLocationState
+import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
@@ -18,13 +20,15 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
+ val customMapStyleUrl: AsyncData,
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList,
val focusedLocation: LocationShareItem?,
- val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
+ val userLocationState: UserLocationState,
val appName: String,
+ val hideUserLocationPuck: Boolean,
val eventSink: (ShowLocationEvent) -> Unit,
) {
val isSheetDraggable = isLive && locationShares.isNotEmpty()
@@ -38,7 +42,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 1ab2310365..cd7ea108bb 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
@@ -11,8 +11,11 @@ package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
+import io.element.android.features.location.impl.common.userlocation.UserLocationState
+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.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
@@ -31,36 +34,34 @@ class ShowLocationStateProvider : PreviewParameterProvider {
),
aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
- hasLocationPermission = true,
- ),
- aShowLocationState(
- hasLocationPermission = true,
- ),
- aShowLocationState(
- hasLocationPermission = true,
- isTrackMyLocation = true,
),
+ aShowLocationState(isTrackMyLocation = true),
+ aShowLocationState(customMapStyleUrl = AsyncData.Loading()),
)
}
private const val APP_NAME = "ApplicationName"
fun aShowLocationState(
+ customMapStyleUrl: AsyncData = AsyncData.Success(null),
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List = listOf(aLocationShareItem(isLive = isLive)),
focusedLocation: LocationShareItem? = locationShares.firstOrNull(),
- hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
+ userLocationState: UserLocationState = UserLocationState(null),
appName: String = APP_NAME,
+ hideUserLocationPuck: Boolean = false,
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
+ customMapStyleUrl = customMapStyleUrl,
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
- hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
+ userLocationState = userLocationState,
+ hideUserLocationPuck = hideUserLocationPuck,
appName = appName,
isLive = isLive,
eventSink = eventSink,
@@ -69,7 +70,7 @@ fun aShowLocationState(
fun aLocationShareItem(
userId: UserId = UserId("@alice:matrix.org"),
- displayName: String = "Alice",
+ displayName: String = USER_NAME_ALICE,
avatarData: AvatarData = AvatarData(
id = userId.value,
name = displayName,
@@ -80,6 +81,7 @@ fun aLocationShareItem(
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,
@@ -88,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 7ac5946723..6b1b33014d 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
@@ -43,13 +43,14 @@ import io.element.android.features.location.impl.common.ui.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.LocationShareRow
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.features.location.impl.common.userlocation.UserLocationTrackingEffect
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
+import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition
@@ -90,7 +91,6 @@ fun ShowLocationView(
}
}
- val userLocationState = rememberUserLocationState(state.hasLocationPermission)
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(SheetValue.Expanded)
)
@@ -100,6 +100,7 @@ fun ShowLocationView(
}
}
MapBottomSheetScaffold(
+ customMapStyleUrl = state.customMapStyleUrl,
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -147,12 +148,15 @@ fun ShowLocationView(
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
+ // Force pointing to NORTH
+ bearing = 0.0,
+ zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
@@ -164,13 +168,19 @@ fun ShowLocationView(
}
},
mapContent = {
- UserLocationPuck(
+ UserLocationTrackingEffect(
cameraState = cameraState,
- locationState = userLocationState,
- trackUserLocation = state.isTrackMyLocation
+ locationState = state.userLocationState,
+ enabled = state.isTrackMyLocation,
)
+ if (!state.hideUserLocationPuck) {
+ UserLocationPuck(
+ cameraState = cameraState,
+ location = state.userLocationState.location,
+ )
+ }
val markers = remember(state.locationShares) {
- state.locationShares.map { it.toMarkerData() }
+ state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
LocationPinMarkers(markers)
},
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 6bb5b1db8c..98af57abba 100644
--- a/features/location/impl/src/main/res/values-cs/translations.xml
+++ b/features/location/impl/src/main/res/values-cs/translations.xml
@@ -2,4 +2,5 @@
"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-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 4a7d801f71..b35d11cd49 100644
--- a/features/location/impl/src/main/res/values-fi/translations.xml
+++ b/features/location/impl/src/main/res/values-fi/translations.xml
@@ -2,4 +2,5 @@
"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-fr/translations.xml b/features/location/impl/src/main/res/values-fr/translations.xml
index 043c180dbc..802b869271 100644
--- a/features/location/impl/src/main/res/values-fr/translations.xml
+++ b/features/location/impl/src/main/res/values-fr/translations.xml
@@ -2,4 +2,5 @@
"Votre historique de localisation en direct sera enregistré dans le salon et visible par les membres après la fin de la session."
"Choisissez la durée pendant laquelle vous partagerez votre position en direct."
+ "Vous n’êtes pas autorisé à partager votre position en direct dans ce salon."
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-ko/translations.xml b/features/location/impl/src/main/res/values-ko/translations.xml
index e283d20b9c..aa23450335 100644
--- a/features/location/impl/src/main/res/values-ko/translations.xml
+++ b/features/location/impl/src/main/res/values-ko/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..08d205d925
--- /dev/null
+++ b/features/location/impl/src/main/res/values-uk/translations.xml
@@ -0,0 +1,6 @@
+
+
+ "Ваша історія поточного місцезнаходження зберігатиметься у кімнаті та буде доступна учасникам після завершення сеансу."
+ "Виберіть, як довго ділитися своїм місцезнаходженням."
+ "У вас немає дозволу ділитися своїм місцезнаходженням у реальному часі в цій кімнаті"
+
diff --git a/features/location/impl/src/main/res/values-zh/translations.xml b/features/location/impl/src/main/res/values-zh/translations.xml
index 6837d3a1eb..86f2f2a9da 100644
--- a/features/location/impl/src/main/res/values-zh/translations.xml
+++ b/features/location/impl/src/main/res/values-zh/translations.xml
@@ -1,5 +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/PermissionsStateFactory.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/PermissionsStateFactory.kt
index a4352444dc..5c7bacb91a 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/PermissionsStateFactory.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/PermissionsStateFactory.kt
@@ -13,10 +13,12 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
fun aPermissionsState(
permissions: PermissionsState.Permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale: Boolean = false,
+ permissionsRequested: Boolean = false,
): PermissionsState {
return PermissionsState(
permissions = permissions,
shouldShowRationale = shouldShowRationale,
+ permissionsAlreadyRequested = permissionsRequested,
eventSink = {},
)
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/FakeUserLocationStateFactory.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/FakeUserLocationStateFactory.kt
new file mode 100644
index 0000000000..f6855c5667
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/FakeUserLocationStateFactory.kt
@@ -0,0 +1,18 @@
+/*
+ * 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 androidx.compose.runtime.Composable
+import io.element.android.features.location.impl.common.userlocation.UserLocationState
+
+class FakeUserLocationStateFactory : UserLocationState.Factory {
+ @Composable
+ override fun create(hasLocationPermission: Boolean): UserLocationState {
+ return UserLocationState(null)
+ }
+}
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..40362bda3c 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,21 +58,48 @@ class LocationConstraintsCheckTest {
)
val locationActions = FakeLocationActions(isLocationEnabled = true)
- val result = checkLocationConstraints(permissionsState, locationActions)
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale)
}
@Test
- fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() {
+ fun `checkLocationConstraints returns PermissionShouldBeRequested when permissions not yet requested`() {
+ val permissionsState = aPermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ permissionsRequested = false,
+ )
+ val locationActions = FakeLocationActions(isLocationEnabled = true)
+
+ val result = checkLocationConstraints(permissionsState, locationActions, SendLiveLocationPermissions.GRANTED)
+
+ assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionShouldBeRequested)
+ }
+
+ @Test
+ fun `checkLocationConstraints returns PermissionDenied when permissions already requested and denied without rationale`() {
+ val permissionsState = aPermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ permissionsRequested = true,
+ )
+ val locationActions = FakeLocationActions(isLocationEnabled = true)
+
+ 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)
- val result = checkLocationConstraints(permissionsState, locationActions)
-
- assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied)
+ assertThat(result).isEqualTo(LocationConstraintsCheck.NotEnoughPowerLevel)
}
}
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt
index 94d909a7ef..9b8ff7a29a 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt
@@ -20,6 +20,7 @@ class FakePermissionsPresenter : PermissionsPresenter {
private var state = PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
+ permissionsAlreadyRequested = false,
eventSink = ::handleEvent,
)
set(value) {
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..6cee53a482
--- /dev/null
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/live/LiveLocationSharingCoordinatorTest.kt
@@ -0,0 +1,122 @@
+/*
+ * 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, liveLocationReceiver { error("boom") })
+ coordinator.register(A_SESSION_ID_2, liveLocationReceiver { delivered += it })
+ 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, liveLocationReceiver { delivered += it })
+
+ 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, liveLocationReceiver { delivered += it })
+
+ 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, liveLocationReceiver { delivered += it })
+
+ 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()
+ }
+}
+
+private fun liveLocationReceiver(
+ onLocation: suspend (Location) -> Unit = {},
+) = object : LiveLocationReceiver {
+ override suspend fun onLocationUpdate(location: Location) = onLocation(location)
+ override suspend fun onUnrecoverableError() = Unit
+}
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..49df684666 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
@@ -11,17 +11,21 @@ package io.element.android.features.location.impl.share
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
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 +34,30 @@ 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,
+ ),
+ userLocationStateFactory = FakeUserLocationStateFactory(),
)
},
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..f4cc135e9e 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,40 +10,63 @@
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.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
+import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
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.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 +77,33 @@ 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 fakeMatrixClient = FakeMatrixClient(
+ sessionId = A_USER_ID,
+ getMapStyleUrlResult = { Result.success(null) },
+ )
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),
+ client: FakeMatrixClient = fakeMatrixClient,
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
- timelineMode = Timeline.Mode.Live,
+ timelineMode = timelineMode,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
locationActions = locationActions,
buildMeta = fakeBuildMeta,
- featureFlagService = fakeFeatureFlagService,
- client = fakeMatrixClient,
+ client = client,
durationFormatter = durationFormatter,
+ liveLocationShareManager = liveLocationShareManager,
+ liveLocationStore = liveLocationStore,
+ userLocationStateFactory = FakeUserLocationStateFactory(),
)
@Test
@@ -85,12 +116,28 @@ class ShareLocationPresenterTest {
)
val shareLocationPresenter = createShareLocationPresenter()
+ shareLocationPresenter.test {
+ val state = awaitFirstItem()
+ assertThat(state.customMapStyleUrl.isLoading()).isFalse()
+ assertThat(state.trackUserLocation).isTrue()
+ assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
+ }
+ }
+
+ @Test
+ fun `present - non-null customMapStyleUrl`() = runTest {
+ val shareLocationPresenter = createShareLocationPresenter(
+ client = FakeMatrixClient(
+ sessionId = A_USER_ID,
+ getMapStyleUrlResult = { Result.success("aUrl") },
+ )
+ )
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
- assertThat(state.trackUserLocation).isTrue()
- assertThat(state.hasLocationPermission).isTrue()
- assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
+ assertThat(state.customMapStyleUrl.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
}
}
@@ -107,14 +154,30 @@ class ShareLocationPresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isTrue()
- assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
+ @Test
+ fun `initial state with permissions not yet requested triggers permission request`() = runTest {
+ val shareLocationPresenter = createShareLocationPresenter()
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ permissionsRequested = false,
+ )
+ )
+
+ shareLocationPresenter.test {
+ skipItems(2)
+ cancelAndIgnoreRemainingEvents()
+ assertThat(fakePermissionsPresenter.events).contains(PermissionsEvents.RequestPermissions)
+ }
+ }
+
@Test
fun `initial state with permissions denied`() = runTest {
val shareLocationPresenter = createShareLocationPresenter()
@@ -122,16 +185,15 @@ class ShareLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
+ permissionsRequested = true,
)
)
moleculeFlow(RecompositionMode.Immediate) {
shareLocationPresenter.present()
}.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
- assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied)
)
@@ -149,10 +211,8 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
- assertThat(initialState.hasLocationPermission).isFalse()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
@@ -171,10 +231,8 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isFalse()
- assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
@@ -192,8 +250,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.trackUserLocation).isTrue()
initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation)
@@ -213,8 +270,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale)
)
@@ -236,7 +292,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
initialState.eventSink(ShareLocationEvent.RequestPermissions)
// Wait for dialog to be dismissed
@@ -254,12 +310,12 @@ class ShareLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
+ permissionsRequested = true,
)
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
initialState.eventSink(ShareLocationEvent.OpenAppSettings)
val settingsOpenedState = awaitItem()
@@ -280,8 +336,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
assertThat(initialState.dialogState).isEqualTo(
ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled)
)
@@ -296,7 +351,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,
@@ -305,9 +368,8 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
- initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
+ val initialState = awaitFirstItem()
+ initialState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val durationDialogState = awaitItem()
assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java)
@@ -316,23 +378,174 @@ class ShareLocationPresenterTest {
}
@Test
- fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest {
- val shareLocationPresenter = createShareLocationPresenter()
+ fun `ShowLiveLocationDurationPicker shows disclaimer when acceptance is missing`() = runTest {
+ val room = FakeJoinedRoom(
+ baseRoom = FakeBaseRoom(
+ roomPermissions = grantedSendLiveLocationPermissions()
+ )
+ )
+ val presenter = createShareLocationPresenter(joinedRoom = room)
fakePermissionsPresenter.givenState(
aPermissionsState(
- permissions = PermissionsState.Permissions.NoneGranted,
+ permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
+ presenter.test {
+ val state = awaitFirstItem()
+
+ 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 {
+ val state = awaitFirstItem()
+ 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 {
+ val state = awaitFirstItem()
+ 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 {
+ val state = awaitFirstItem()
+
+ 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"),
+ roomPermissions = grantedSendLiveLocationPermissions()
+ ),
+ )
+ 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 {
+ val state = awaitFirstItem()
+
+ 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 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,
+ shouldShowRationale = false,
+ permissionsRequested = true,
+ )
+ )
+
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
- // Dismiss initial dialog
+ val initialState = awaitFirstItem()
+ // Dismiss initial dialog to allow re-triggering it
initialState.eventSink(ShareLocationEvent.DismissDialog)
val dismissedState = awaitItem()
- dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
+ dismissedState.eventSink(ShareLocationEvent.InitiateLiveLocationShare)
val constraintDialogState = awaitItem()
assertThat(constraintDialogState.dialogState).isEqualTo(
@@ -361,8 +574,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- skipItems(1)
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
@@ -415,7 +627,7 @@ class ShareLocationPresenterTest {
)
shareLocationPresenter.test {
- val initialState = awaitItem()
+ val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
@@ -447,4 +659,88 @@ 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 {
+ val state = awaitFirstItem()
+ 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 {
+ val state = awaitFirstItem()
+ 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 {
+ val state = awaitFirstItem()
+ assertThat(state.canShareLiveLocation).isFalse()
+ }
+ }
+
+ private suspend fun ReceiveTurbine.awaitFirstItem(): T {
+ skipItems(2)
+ return awaitItem()
+ }
}
+
+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..c53e463280 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,16 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
@@ -23,130 +25,155 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ShareLocationViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class ShareLocationViewTest : RobolectricTest() {
@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,
eventSink = eventsRecorder
),
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,
eventSink = eventsRecorder
),
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 91df447e2a..23b7e9bf4d 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
@@ -14,10 +14,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
+import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
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.FakeMatrixClient
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
@@ -34,6 +37,7 @@ class DefaultShowLocationEntryPointTest {
fun `test node builder`() {
val entryPoint = DefaultShowLocationEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
+ val joinedRoom = FakeJoinedRoom()
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
@@ -45,7 +49,10 @@ class DefaultShowLocationEntryPointTest {
buildMeta = aBuildMeta(),
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
- joinedRoom = FakeJoinedRoom(),
+ joinedRoom = joinedRoom,
+ client = FakeMatrixClient(),
+ liveLocationShareManager = FakeActiveLiveLocationShareManager(),
+ userLocationStateFactory = FakeUserLocationStateFactory(),
)
},
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
index 4042cb4c0c..0b8e04abf8 100644
--- 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
@@ -9,7 +9,7 @@ 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.api.room.location.LiveLocationShare
+import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import org.junit.Test
class LiveLocationShareComparatorTest {
@@ -55,15 +55,3 @@ class LiveLocationShareComparatorTest {
assertThat(sortedShares).containsExactly(newerShare, olderShare).inOrder()
}
}
-
-private fun aLiveLocationShare(
- userId: UserId,
- startTimestamp: Long,
-): LiveLocationShare {
- return LiveLocationShare(
- userId = userId,
- lastLocation = null,
- startTimestamp = startTimestamp,
- endTimestamp = startTimestamp + 1_000L,
- )
-}
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 f38e8dae60..b579a79e4f 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
@@ -15,28 +15,33 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
+import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
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.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.MatrixClient
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.LastLocation
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
+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.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
+import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalCoroutinesApi::class)
class ShowLocationPresenterTest {
@@ -60,6 +65,8 @@ class ShowLocationPresenterTest {
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
+ client: MatrixClient = FakeMatrixClient(),
+ liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@@ -68,51 +75,24 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
+ client = client,
+ liveLocationShareManager = liveLocationShareManager,
+ userLocationStateFactory = FakeUserLocationStateFactory(),
)
@Test
- fun `emits initial state with no location permission`() = runTest {
- fakePermissionsPresenter.givenState(
- aPermissionsState(
- permissions = PermissionsState.Permissions.NoneGranted,
- shouldShowRationale = false,
+ fun `present - non-null customMapStyleUrl`() = runTest {
+ val shareLocationPresenter = createShowLocationPresenter(
+ client = FakeMatrixClient(
+ sessionId = A_USER_ID,
+ getMapStyleUrlResult = { Result.success("aUrl") },
)
)
-
- val presenter = createShowLocationPresenter()
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.hasLocationPermission).isFalse()
- assertThat(initialState.isTrackMyLocation).isFalse()
- }
- }
-
- @Test
- fun `emits initial state location permission denied once`() = runTest {
- fakePermissionsPresenter.givenState(
- aPermissionsState(
- permissions = PermissionsState.Permissions.NoneGranted,
- shouldShowRationale = true,
- )
- )
-
- val presenter = createShowLocationPresenter()
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.hasLocationPermission).isFalse()
- assertThat(initialState.isTrackMyLocation).isFalse()
- }
- }
-
- @Test
- fun `emits initial state with location permission`() = runTest {
- fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
-
- val presenter = createShowLocationPresenter()
- presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.hasLocationPermission).isTrue()
- assertThat(initialState.isTrackMyLocation).isFalse()
+ shareLocationPresenter.test {
+ val state = awaitItem()
+ assertThat(state.customMapStyleUrl.isLoading()).isTrue()
+ val finalState = awaitItem()
+ assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
}
}
@@ -122,9 +102,8 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter()
presenter.test {
- val initialState = awaitItem()
- assertThat(initialState.hasLocationPermission).isTrue()
- assertThat(initialState.isTrackMyLocation).isFalse()
+ assertThat(awaitItem().isTrackMyLocation).isFalse()
+ assertThat(awaitItem().isTrackMyLocation).isTrue()
}
}
@@ -143,18 +122,18 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
- val presenter = createShowLocationPresenter()
+ val presenter = createShowLocationPresenter(
+ client = FakeMatrixClient(
+ getMapStyleUrlResult = { Result.success(null) }
+ )
+ )
presenter.test {
val initialState = awaitItem()
- assertThat(initialState.hasLocationPermission).isTrue()
assertThat(initialState.isTrackMyLocation).isFalse()
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
val trackMyLocationState = awaitItem()
- delay(1)
-
- assertThat(trackMyLocationState.hasLocationPermission).isTrue()
assertThat(trackMyLocationState.isTrackMyLocation).isTrue()
// Swipe the map to switch mode
@@ -162,7 +141,6 @@ class ShowLocationPresenterTest {
val trackLocationDisabledState = awaitItem()
assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse()
- assertThat(trackLocationDisabledState.hasLocationPermission).isTrue()
}
}
@@ -185,14 +163,12 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
- assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
- assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@@ -205,7 +181,7 @@ class ShowLocationPresenterTest {
)
)
val presenter = createShowLocationPresenter()
- presenter.test {
+ presenter.test {
// Skip initial state
val initialState = awaitItem()
@@ -214,7 +190,6 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
- assertThat(trackLocationState.hasLocationPermission).isFalse()
// Continue the dialog sends permission request to the permissions presenter
trackLocationState.eventSink(ShowLocationEvent.RequestPermissions)
@@ -222,12 +197,34 @@ class ShowLocationPresenterTest {
}
}
+ @Test
+ fun `TrackMyLocation with permissions not yet requested triggers permission request`() = runTest {
+ fakePermissionsPresenter.givenState(
+ aPermissionsState(
+ permissions = PermissionsState.Permissions.NoneGranted,
+ shouldShowRationale = false,
+ permissionsRequested = false,
+ )
+ )
+
+ val presenter = createShowLocationPresenter()
+ presenter.test {
+ val initialState = awaitItem()
+
+ initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
+
+ assertThat(fakePermissionsPresenter.events).contains(PermissionsEvents.RequestPermissions)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun `permission denied dialog dismiss`() = runTest {
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
+ permissionsRequested = true,
)
)
@@ -241,14 +238,12 @@ class ShowLocationPresenterTest {
val trackLocationState = awaitItem()
assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied)
assertThat(trackLocationState.isTrackMyLocation).isFalse()
- assertThat(trackLocationState.hasLocationPermission).isFalse()
// Dismiss the dialog
initialState.eventSink(ShowLocationEvent.DismissDialog)
val dialogDismissedState = awaitItem()
assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None)
assertThat(dialogDismissedState.isTrackMyLocation).isFalse()
- assertThat(dialogDismissedState.hasLocationPermission).isFalse()
}
}
@@ -258,6 +253,7 @@ class ShowLocationPresenterTest {
aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
+ permissionsRequested = true,
)
)
@@ -296,7 +292,6 @@ class ShowLocationPresenterTest {
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
- assertThat(initialState.hasLocationPermission).isTrue()
// Try to track location when location services are disabled
initialState.eventSink(ShowLocationEvent.TrackMyLocation(true))
@@ -463,24 +458,51 @@ class ShowLocationPresenterTest {
assertThat(state.isSheetDraggable).isFalse()
}
}
-}
-private fun aLiveLocationShare(
- userId: UserId,
- geoUri: String = "geo:48.8584,2.2945",
- timestamp: Long = 0L,
- startTimestamp: Long = 0L,
- endTimestamp: Long = Long.MAX_VALUE,
- assetType: AssetType = AssetType.SENDER,
-): LiveLocationShare {
- return LiveLocationShare(
- userId = userId,
- lastLocation = LastLocation(
- geoUri = geoUri,
- timestamp = timestamp,
- assetType = assetType,
- ),
- startTimestamp = startTimestamp,
- endTimestamp = endTimestamp,
- )
+ @Test
+ fun `static mode never hides user location puck`() = runTest {
+ val presenter = createShowLocationPresenter()
+ presenter.test {
+ val state = awaitItem()
+ assertThat(state.hideUserLocationPuck).isFalse()
+ }
+ }
+
+ @Test
+ fun `live mode does not hide user location puck when not sharing`() = runTest {
+ val presenter = createShowLocationPresenter(
+ mode = ShowLocationMode.Live(senderId = A_USER_ID),
+ )
+ presenter.test {
+ val state = awaitItem()
+ assertThat(state.hideUserLocationPuck).isFalse()
+ }
+ }
+
+ @Test
+ fun `live mode user location state uses own share position when sharing`() = runTest {
+ val ownLiveLocationShare = aLiveLocationShare()
+ val fakeRoom = FakeJoinedRoom(
+ liveLocationSharesFlow = MutableStateFlow(listOf(ownLiveLocationShare))
+ )
+ val manager = FakeActiveLiveLocationShareManager(
+ startShareLambda = { _, _ -> Result.success(Unit) }
+ )
+ manager.startShare(fakeRoom.roomId, 1.hours)
+
+ val presenter = createShowLocationPresenter(
+ mode = ShowLocationMode.Live(senderId = A_USER_ID),
+ joinedRoom = fakeRoom,
+ liveLocationShareManager = manager,
+ )
+ val ownLocation = ownLiveLocationShare.lastLocation?.geoUri?.let(Location::fromGeoUri)
+ presenter.test {
+ skipItems(1)
+ val state = awaitItem()
+ assertThat(state.hideUserLocationPuck).isTrue()
+ val location = requireNotNull(state.userLocationState.location)
+ assertThat(location.position.value.latitude).isEqualTo(ownLocation?.lat)
+ assertThat(location.position.value.longitude).isEqualTo(ownLocation?.lon)
+ }
+ }
}
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..053c26c179 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,17 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags
@@ -26,115 +28,110 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class ShowLocationViewTest {
- @get:Rule val rule = createAndroidComposeRule()
+class ShowLocationViewTest : RobolectricTest() {
@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/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt
new file mode 100644
index 0000000000..d67668caf1
--- /dev/null
+++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/DeviceUnlockEntryPoint.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.lockscreen.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+/**
+ * An entry point for features that want to lock the screen and require
+ * the user to unlock it before they can interact with the app.
+ * - if the system lock is available, it will be used to unlock the screen.
+ * - if the system lock is not available, but app lock is available, it will be used to unlock the screen.
+ * - if neither is available, the screen will be unlocked immediately.
+ *
+ * The Node provided by [createNode] has to be added as a PermanentChild.
+ */
+interface DeviceUnlockEntryPoint : FeatureEntryPoint {
+ fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ ): Node
+
+ fun requestUnlock(callback: Callback)
+
+ interface Callback {
+ fun onCancel()
+ fun onUnlock()
+ }
+}
diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts
index 63aec590de..414b4b5909 100644
--- a/features/lockscreen/impl/build.gradle.kts
+++ b/features/lockscreen/impl/build.gradle.kts
@@ -48,6 +48,7 @@ dependencies {
implementation(libs.androidx.biometric)
testCommonDependencies(libs, true)
+ testImplementation(projects.features.lockscreen.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt
new file mode 100644
index 0000000000..f4b78968f8
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultDeviceUnlockEntryPoint.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.lockscreen.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
+import io.element.android.features.lockscreen.impl.device.DeviceUnlockCallbackHolder
+import io.element.android.features.lockscreen.impl.device.DeviceUnlockNode
+import io.element.android.libraries.architecture.createNode
+
+@ContributesBinding(AppScope::class)
+class DefaultDeviceUnlockEntryPoint(
+ private val deviceUnlockCallbackHolder: DeviceUnlockCallbackHolder,
+) : DeviceUnlockEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ ): Node {
+ return parentNode.createNode(
+ buildContext = buildContext,
+ )
+ }
+
+ override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
+ deviceUnlockCallbackHolder.requestUnlock(callback)
+ }
+}
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..74d36b0834 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,8 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
- override fun setup() {
+ override suspend fun setup() {
+ if (cryptoObject != null) return
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -86,7 +87,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)
}
}
@@ -109,7 +110,9 @@ private class AuthenticationCallback(
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
- if (result.cryptoObject?.cipher.isValid()) {
+ if (result.authenticationType == BiometricPrompt.AUTHENTICATION_RESULT_TYPE_BIOMETRIC &&
+ result.cryptoObject?.cipher.isValid() ||
+ result.authenticationType == BiometricPrompt.AUTHENTICATION_RESULT_TYPE_DEVICE_CREDENTIAL) {
callbacks.forEach { it.onBiometricAuthenticationSuccess() }
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
} else {
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..cccfa143b9 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
@@ -21,15 +21,31 @@ interface BiometricAuthenticatorManager {
*/
val hasAvailableAuthenticator: Boolean
+ /**
+ * If the device is secured for example with a pin, a pattern, a password, or biometric.
+ */
+ val canUseDeviceUnlock: Boolean
+
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.
*/
@Composable
fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator
+ /**
+ * Remember a biometric authenticator ready for unlocking the app, using the device settings.
+ */
+ @Composable
+ fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator
+
/**
* Remember a biometric authenticator ready for confirmation.
*/
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..80ea43b44d 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
@@ -35,6 +35,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList
@@ -69,6 +70,12 @@ class DefaultBiometricAuthenticatorManager(
get() = lockScreenConfig.isStrongBiometricsEnabled &&
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
+ /**
+ * Returns true if a device credential method (i.e.: pattern, pin) can be used.
+ */
+ private val canUseDeviceCredentialAuth: Boolean
+ get() = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS
+
/**
* Returns true if any biometric method (weak or strong) can be used.
*/
@@ -78,12 +85,12 @@ class DefaultBiometricAuthenticatorManager(
override val isDeviceSecured: Boolean
get() = keyguardManager.isDeviceSecure
+ override val canUseDeviceUnlock: Boolean
+ get() = isDeviceSecured && (canUseWeakBiometricAuth || canUseStrongBiometricAuth || canUseDeviceCredentialAuth)
+
private val internalCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricSetupError() {
- coroutineScope.launch {
- lockScreenStore.setIsBiometricUnlockAllowed(false)
- secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
- }
+ coroutineScope.launch { disable() }
}
}
@@ -102,6 +109,28 @@ class DefaultBiometricAuthenticatorManager(
isAvailable = isAvailable,
promptTitle = promptTitle,
promptNegative = promptNegative,
+ forDeviceUnlock = false,
+ )
+ }
+
+ @Composable
+ override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
+ val isAvailableTrigger by remember {
+ // Need to trigger the creation of BiometricAuthenticator twice, else the callback will not be ready.
+ // (the issue already exists in [rememberUnlockBiometricAuthenticator])
+ flowOf(false, true)
+ }.collectAsState(initial = false)
+ val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
+ val isAvailable by remember(lifecycleState) {
+ derivedStateOf { isAvailableTrigger && canUseDeviceUnlock }
+ }
+ val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
+ val promptNegative = null
+ return rememberBiometricAuthenticator(
+ isAvailable = isAvailable,
+ promptTitle = promptTitle,
+ promptNegative = promptNegative,
+ forDeviceUnlock = true,
)
}
@@ -117,14 +146,21 @@ class DefaultBiometricAuthenticatorManager(
isAvailable = isAvailable,
promptTitle = promptTitle,
promptNegative = promptNegative,
+ forDeviceUnlock = false,
)
}
+ override suspend fun disable() {
+ lockScreenStore.setIsBiometricUnlockAllowed(false)
+ secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
+ }
+
@Composable
private fun rememberBiometricAuthenticator(
isAvailable: Boolean,
promptTitle: String,
- promptNegative: String,
+ promptNegative: String?,
+ forDeviceUnlock: Boolean,
): BiometricAuthenticator {
val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) {
@@ -134,11 +170,21 @@ class DefaultBiometricAuthenticatorManager(
canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
else -> 0
}
- val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
- setTitle(promptTitle)
- setNegativeButtonText(promptNegative)
- setAllowedAuthenticators(authenticators)
- }.build()
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(promptTitle)
+ .apply {
+ if (promptNegative != null) {
+ setNegativeButtonText(promptNegative)
+ }
+ if (forDeviceUnlock) {
+ setAllowedAuthenticators(
+ authenticators or if (canUseDeviceCredentialAuth) BiometricManager.Authenticators.DEVICE_CREDENTIAL else 0
+ )
+ } else {
+ setAllowedAuthenticators(authenticators)
+ }
+ }
+ .build()
DefaultBiometricAuthentication(
activity = activity,
promptInfo = promptInfo,
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt
new file mode 100644
index 0000000000..cf8b23d3f6
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockCallbackHolder.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.features.lockscreen.impl.device
+
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.SingleIn
+import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+@Inject
+@SingleIn(AppScope::class)
+class DeviceUnlockCallbackHolder {
+ private val _deviceUnlockCallback = MutableStateFlow(null)
+ val deviceUnlockCallback: StateFlow = _deviceUnlockCallback
+
+ fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
+ _deviceUnlockCallback.value = callback
+ }
+
+ fun onDone() {
+ _deviceUnlockCallback.value = null
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt
new file mode 100644
index 0000000000..8b894875cb
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockEvent.kt
@@ -0,0 +1,12 @@
+/*
+ * 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.lockscreen.impl.device
+
+sealed interface DeviceUnlockEvent {
+ data object CancelPinCode : DeviceUnlockEvent
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt
new file mode 100644
index 0000000000..9b73239725
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockNode.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.lockscreen.impl.device
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+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.features.lockscreen.impl.unlock.PinUnlockPresenter
+import io.element.android.features.lockscreen.impl.unlock.PinUnlockView
+import io.element.android.libraries.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+@AssistedInject
+class DeviceUnlockNode(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: DeviceUnlockPresenter,
+ private val pinUnlockPresenterFactory: PinUnlockPresenter.Factory,
+) : Node(buildContext, plugins = plugins) {
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ if (state.showApplicationPinCode) {
+ val pinUnlockPresenter = remember {
+ pinUnlockPresenterFactory.create(forDeviceUnlock = true)
+ }
+ val pinState = pinUnlockPresenter.present()
+ PinUnlockView(
+ state = pinState,
+ isInAppUnlock = true,
+ onCancel = {
+ state.eventSink(DeviceUnlockEvent.CancelPinCode)
+ },
+ modifier = modifier,
+ )
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt
new file mode 100644
index 0000000000..676340764e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenter.kt
@@ -0,0 +1,85 @@
+/*
+ * 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.lockscreen.impl.device
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import dev.zacsweers.metro.Inject
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
+import io.element.android.libraries.architecture.Presenter
+import kotlinx.coroutines.flow.first
+
+@Inject
+class DeviceUnlockPresenter(
+ private val pinUnlockHelper: PinUnlockHelper,
+ private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
+ private val deviceUnlockCallbackHolder: DeviceUnlockCallbackHolder,
+ private val pinCodeManager: PinCodeManager,
+) : Presenter {
+ @Composable
+ override fun present(): DeviceUnlockState {
+ var showApplicationPinCode by remember {
+ mutableStateOf(false)
+ }
+
+ val biometricUnlock = biometricAuthenticatorManager.rememberUnlockDeviceBiometricAuthenticator()
+ val deviceUnlockCallback by deviceUnlockCallbackHolder.deviceUnlockCallback.collectAsState()
+ val canUseDeviceUnlock = biometricAuthenticatorManager.canUseDeviceUnlock
+
+ fun setUnlock(isUnlock: Boolean) {
+ deviceUnlockCallback?.let {
+ if (isUnlock) {
+ it.onUnlock()
+ } else {
+ it.onCancel()
+ }
+ }
+ showApplicationPinCode = false
+ deviceUnlockCallbackHolder.onDone()
+ }
+
+ LaunchedEffect(biometricUnlock, canUseDeviceUnlock, deviceUnlockCallback) {
+ if (deviceUnlockCallback != null) {
+ if (canUseDeviceUnlock) {
+ biometricUnlock.setup()
+ biometricUnlock.authenticate()
+ } else if (pinCodeManager.hasPinCode().first()) {
+ showApplicationPinCode = true
+ } else {
+ // No security, unlock immediately
+ setUnlock(true)
+ }
+ }
+ }
+
+ pinUnlockHelper.OnUnlockEffect { isUnlock ->
+ setUnlock(isUnlock)
+ }
+
+ fun handleEvent(event: DeviceUnlockEvent) {
+ when (event) {
+ DeviceUnlockEvent.CancelPinCode -> {
+ showApplicationPinCode = false
+ setUnlock(false)
+ }
+ }
+ }
+
+ return DeviceUnlockState(
+ showApplicationPinCode = showApplicationPinCode,
+ eventSink = ::handleEvent,
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt
new file mode 100644
index 0000000000..48d2181605
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockState.kt
@@ -0,0 +1,13 @@
+/*
+ * 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.lockscreen.impl.device
+
+data class DeviceUnlockState(
+ val showApplicationPinCode: Boolean,
+ val eventSink: (DeviceUnlockEvent) -> Unit,
+)
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/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt
index 0bd26729d5..a6e2c1265c 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt
@@ -103,8 +103,15 @@ class LockScreenSettingsFlowNode(
override fun onUnlock() {
backstack.newRoot(NavTarget.Settings)
}
+
+ override fun onCancel() {
+ // Should not happen in this context.
+ }
}
- createNode(buildContext, plugins = listOf(callback))
+ val inputs = PinUnlockNode.Inputs(
+ forDeviceUnlock = false,
+ )
+ createNode(buildContext, plugins = listOf(callback, inputs))
}
NavTarget.SetupPin -> {
createNode(buildContext)
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/PinUnlockHelper.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt
index 2a475814c9..10de1f23a5 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockHelper.kt
@@ -24,17 +24,23 @@ class PinUnlockHelper(
private val pinCodeManager: PinCodeManager
) {
@Composable
- fun OnUnlockEffect(onUnlock: () -> Unit) {
+ fun OnUnlockEffect(onUnlock: (Boolean) -> Unit) {
val latestOnUnlock by rememberUpdatedState(onUnlock)
DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricAuthenticationSuccess() {
- latestOnUnlock()
+ latestOnUnlock(true)
+ }
+
+ override fun onBiometricAuthenticationFailed(error: Exception?) {
+ if (error != null) {
+ latestOnUnlock(false)
+ }
}
}
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
- latestOnUnlock()
+ latestOnUnlock(true)
}
}
biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt
index 459e165e00..47e7715ea5 100644
--- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt
@@ -17,7 +17,9 @@ 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)
@@ -25,13 +27,23 @@ import io.element.android.libraries.di.SessionScope
class PinUnlockNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
- private val presenter: PinUnlockPresenter,
+ presenterFactory: PinUnlockPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onUnlock()
+
+ // For feature unlock
+ fun onCancel()
}
+ data class Inputs(
+ val forDeviceUnlock: Boolean,
+ ) : NodeInputs
+
private val callback: Callback = callback()
+ private val inputs: Inputs = inputs()
+
+ private val presenter: PinUnlockPresenter = presenterFactory.create(inputs.forDeviceUnlock)
@Composable
override fun View(modifier: Modifier) {
@@ -46,6 +58,7 @@ class PinUnlockNode(
// UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true.
// It's set to false in PinUnlockActivity.
isInAppUnlock = true,
+ onCancel = callback::onCancel,
modifier = modifier
)
}
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..65afba669d 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
@@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
-import dev.zacsweers.metro.Inject
+import dev.zacsweers.metro.Assisted
+import dev.zacsweers.metro.AssistedFactory
+import dev.zacsweers.metro.AssistedInject
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
@@ -32,8 +34,9 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
-@Inject
+@AssistedInject
class PinUnlockPresenter(
+ @Assisted private val forDeviceUnlock: Boolean,
private val pinCodeManager: PinCodeManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase,
@@ -41,6 +44,11 @@ class PinUnlockPresenter(
private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
) : Presenter {
+ @AssistedFactory
+ interface Factory {
+ fun create(forDeviceUnlock: Boolean): PinUnlockPresenter
+ }
+
@Composable
override fun present(): PinUnlockState {
val pinEntryState = remember {
@@ -69,7 +77,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) {
@@ -92,36 +106,37 @@ class PinUnlockPresenter(
}
}
pinUnlockHelper.OnUnlockEffect {
- isUnlocked.value = true
+ isUnlocked.value = it
}
- 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)
}
}
}
return PinUnlockState(
+ canNavigateBack = forDeviceUnlock,
pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
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..1aae6afa51 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
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
data class PinUnlockState(
+ val canNavigateBack: Boolean,
val pinEntry: AsyncData,
val showWrongPinTitle: Boolean,
val remainingAttempts: AsyncData,
@@ -23,11 +24,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..aef2c057a2 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,28 @@ 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,
+ ),
+ aPinUnlockState(canNavigateBack = true),
+ )
+}
+
+open class PinUnlockStateCompactProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aPinUnlockState(),
+ aPinUnlockState(canNavigateBack = true)
)
}
fun aPinUnlockState(
- pinEntry: PinEntry = PinEntry.createEmpty(4),
+ canNavigateBack: Boolean = false,
+ pinEntry: AsyncData = AsyncData.Success(PinEntry.createEmpty(4)),
remainingAttempts: AsyncData = AsyncData.Success(3),
showWrongPinTitle: Boolean = false,
showSignOutPrompt: Boolean = false,
@@ -44,7 +61,8 @@ fun aPinUnlockState(
isUnlocked: Boolean = false,
signOutAction: AsyncAction = AsyncAction.Uninitialized,
) = PinUnlockState(
- pinEntry = AsyncData.Success(pinEntry),
+ canNavigateBack = canNavigateBack,
+ 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..0292b29a0f 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
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
@@ -50,6 +51,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog
+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.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -65,21 +67,22 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PinUnlockView(
state: PinUnlockState,
isInAppUnlock: Boolean,
+ onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
when (event) {
- Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
+ Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvent.OnUseBiometric)
else -> Unit
}
}
Surface(modifier) {
- PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
+ PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock, onCancel = onCancel)
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 +98,7 @@ fun PinUnlockView(
if (state.showBiometricUnlockError) {
ErrorDialog(
content = state.biometricUnlockErrorMessage ?: "",
- onSubmit = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
+ onSubmit = { state.eventSink(PinUnlockEvent.ClearBiometricError) }
)
}
}
@@ -105,6 +108,7 @@ fun PinUnlockView(
private fun PinUnlockPage(
state: PinUnlockState,
isInAppUnlock: Boolean,
+ onCancel: () -> Unit,
) {
BoxWithConstraints {
val commonModifier = Modifier
@@ -125,10 +129,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,7 +148,7 @@ private fun PinUnlockPage(
pinEntry = pinEntry,
isSecured = true,
onValueChange = {
- state.eventSink(PinUnlockEvents.OnPinEntryChanged(it))
+ state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
},
modifier = Modifier
.focusRequester(focusRequester)
@@ -154,7 +158,7 @@ private fun PinUnlockPage(
} else {
PinKeypad(
onClick = {
- state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
+ state.eventSink(PinUnlockEvent.OnPinKeypadPressed(it))
},
maxWidth = constraints.maxWidth,
maxHeight = constraints.maxHeight,
@@ -177,6 +181,15 @@ private fun PinUnlockPage(
modifier = commonModifier,
)
}
+ if (state.canNavigateBack) {
+ BackButton(
+ onClick = onCancel,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .systemBarsPadding()
+ .padding(8.dp),
+ )
+ }
}
}
@@ -311,14 +324,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
@@ -361,6 +386,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
PinUnlockView(
state = state,
isInAppUnlock = true,
+ onCancel = {},
)
}
}
@@ -372,6 +398,19 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
PinUnlockView(
state = state,
isInAppUnlock = false,
+ onCancel = {},
+ )
+ }
+}
+
+@Composable
+@Preview(heightDp = 480, widthDp = 800)
+internal fun PinUnlockViewCompactPreview(@PreviewParameter(PinUnlockStateCompactProvider::class) state: PinUnlockState) {
+ ElementPreview {
+ PinUnlockView(
+ state = state,
+ isInAppUnlock = false,
+ onCancel = {},
)
}
}
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 34adfeca99..613b1cf79e 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
@@ -41,7 +41,7 @@ class PinUnlockActivity : AppCompatActivity() {
}
}
- @Inject lateinit var presenter: PinUnlockPresenter
+ @Inject lateinit var presenterFactory: PinUnlockPresenter.Factory
@Inject lateinit var lockScreenService: LockScreenService
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService
@@ -52,6 +52,7 @@ class PinUnlockActivity : AppCompatActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
bindings().inject(this)
+ val presenter = presenterFactory.create(forDeviceUnlock = false)
setContent {
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = null)
@@ -67,6 +68,9 @@ class PinUnlockActivity : AppCompatActivity() {
PinUnlockView(
state = state,
isInAppUnlock = false,
+ onCancel = {
+ // Should not happen
+ },
)
}
}
diff --git a/features/lockscreen/impl/src/main/res/values-be/translations.xml b/features/lockscreen/impl/src/main/res/values-be/translations.xml
index 7667034623..7b714d329d 100644
--- a/features/lockscreen/impl/src/main/res/values-be/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-be/translations.xml
@@ -35,5 +35,5 @@
"Выкарыстоўваць біяметрыю"
"Выкарыстоўваць PIN-код"
- "Выхад…"
+ "Выдаленне прылады…"
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-et/translations.xml b/features/lockscreen/impl/src/main/res/values-et/translations.xml
index ac25914f8c..7137c09b60 100644
--- a/features/lockscreen/impl/src/main/res/values-et/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-et/translations.xml
@@ -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-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-ru/translations.xml b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
index cb71dfc88c..7bbdef1594 100644
--- a/features/lockscreen/impl/src/main/res/values-ru/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
@@ -15,9 +15,9 @@
"Используйте %1$s для разблокировки приложения"
"Выберите PIN-код"
"Подтвердите PIN-код"
- "Установите блокировку на %1$s, чтобы повысить безопасность ваших чатов.
+ "Установите блокировку на %1$s, чтобы повысить безопасность Ваших чатов.
-Выберите код, который трудно забыть. Если вы забудете PIN-код, вам придется выйти из приложения."
+Выберите код, который трудно забыть. Если Вы забудете PIN-код, Вам придется выйти из аккаунта."
"Этот PIN-код небезопасен."
"Выбрать другой PIN-код"
"Повторите PIN-код"
@@ -25,14 +25,14 @@
"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код"
"Это устройство удаляется"
- - "У вас осталась %1$d попытка разблокировки"
- - "У вас остались %1$d попытки разблокировки"
- - "У вас осталось %1$d попыток разблокировки"
+ - "У Вас осталась %1$d попытка разблокировки"
+ - "У Вас остались %1$d попытки разблокировки"
+ - "У Вас осталось %1$d попыток разблокировки"
- - "Неверный PIN-код. У вас осталась %1$d попытка"
- - "Неверный PIN-код. У вас остались %1$d попытки"
- - "Неверный PIN-код. У вас осталось %1$d попыток"
+ - "Неверный PIN-код. У Вас осталась %1$d попытка"
+ - "Неверный PIN-код. У Вас остались %1$d попытки"
+ - "Неверный PIN-код. У Вас осталось %1$d попыток"
"Использовать биометрию"
"Использовать PIN-код"
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..e8fdf50bfd 100644
--- a/features/lockscreen/impl/src/main/res/values-sk/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
@@ -23,7 +23,7 @@ Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplik
"Zadajte prosím ten istý PIN dvakrát"
"PIN kódy sa nezhodujú"
"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."
- "Prebieha odhlasovanie"
+ "Toto zariadenie sa odstraňuje"
- "Máte %1$d pokus na odomknutie"
- "Máte %1$d pokusy na odomknutie"
@@ -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-zh/translations.xml b/features/lockscreen/impl/src/main/res/values-zh/translations.xml
index 62da77cbb5..f3e93668fd 100644
--- a/features/lockscreen/impl/src/main/res/values-zh/translations.xml
+++ b/features/lockscreen/impl/src/main/res/values-zh/translations.xml
@@ -32,5 +32,5 @@
"使用生物识别"
"使用 PIN 码"
- "正在删除设备……"
+ "正在移除设备…"
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt
index e99223735f..e2b410e663 100644
--- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPointIntentTest.kt
@@ -8,15 +8,13 @@
package io.element.android.features.lockscreen.impl
-import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-@RunWith(AndroidJUnit4::class)
-class DefaultLockScreenEntryPointIntentTest {
+class DefaultLockScreenEntryPointIntentTest : RobolectricTest() {
@Test
fun `test pin unlock intent`() {
val entryPoint = DefaultLockScreenEntryPoint()
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..0fb2e9df8b 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
@@ -8,10 +8,18 @@
package io.element.android.features.lockscreen.impl.biometric
+import io.element.android.tests.testutils.simulateLongTask
+
class FakeBiometricAuthenticator(
override val isActive: Boolean = false,
- private val authenticateLambda: suspend () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
+ private val setupLambda: () -> Unit = { },
+ private val authenticateLambda: () -> BiometricAuthenticator.AuthenticationResult = { BiometricAuthenticator.AuthenticationResult.Success },
) : BiometricAuthenticator {
- override fun setup() = Unit
- override suspend fun authenticate() = authenticateLambda()
+ override suspend fun setup() = simulateLongTask {
+ setupLambda()
+ }
+
+ override suspend fun authenticate() = simulateLongTask {
+ 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..1a70f25d30 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
@@ -12,9 +12,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
class FakeBiometricAuthenticatorManager(
- override var isDeviceSecured: Boolean = true,
- override var hasAvailableAuthenticator: Boolean = false,
+ override val isDeviceSecured: Boolean = true,
+ override val canUseDeviceUnlock: Boolean = true,
+ override val hasAvailableAuthenticator: Boolean = false,
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
+ private val disableLambda: suspend () -> Unit = { },
) : BiometricAuthenticatorManager {
override fun addCallback(callback: BiometricAuthenticator.Callback) {
// no-op
@@ -27,7 +29,14 @@ class FakeBiometricAuthenticatorManager(
@Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
return remember {
- createBiometricAuthenticator()
+ createBiometricAuthenticator()
+ }
+ }
+
+ @Composable
+ override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
+ return remember {
+ createBiometricAuthenticator()
}
}
@@ -37,4 +46,8 @@ class FakeBiometricAuthenticatorManager(
createBiometricAuthenticator()
}
}
+
+ override suspend fun disable() {
+ disableLambda()
+ }
}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt
new file mode 100644
index 0000000000..52aeaa04a8
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/device/DeviceUnlockPresenterTest.kt
@@ -0,0 +1,152 @@
+/*
+ * 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.lockscreen.impl.device
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticator
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricAuthenticatorManager
+import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.unlock.PinUnlockHelper
+import io.element.android.features.lockscreen.test.FakeDeviceUnlockEntryPointCallback
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.test
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DeviceUnlockPresenterTest {
+ @Test
+ fun `present - when unlock requested and device unlock available, use biometric authenticator`() = runTest {
+ val setupLambda = lambdaRecorder { }
+ val authenticateLambda = lambdaRecorder {
+ BiometricAuthenticator.AuthenticationResult.Success
+ }
+ val fakeBiometricAuthenticator = FakeBiometricAuthenticator(
+ setupLambda = setupLambda,
+ authenticateLambda = authenticateLambda,
+ )
+ val biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(
+ canUseDeviceUnlock = true,
+ createBiometricAuthenticator = { fakeBiometricAuthenticator },
+ )
+ val callbackHolder = DeviceUnlockCallbackHolder()
+ val callback = FakeDeviceUnlockEntryPointCallback()
+
+ createDeviceUnlockPresenter(
+ biometricAuthenticatorManager = biometricAuthenticatorManager,
+ callbackHolder = callbackHolder,
+ ).test {
+ awaitItem().also { state ->
+ assertThat(state.showApplicationPinCode).isFalse()
+ }
+ callbackHolder.requestUnlock(callback)
+ advanceUntilIdle()
+ setupLambda.assertions().isCalledOnce()
+ authenticateLambda.assertions().isCalledOnce()
+ skipItems(1)
+ }
+ }
+
+ @Test
+ fun `present - when unlock requested and device unlock unavailable and app pin is configured, show app pin`() = runTest {
+ val callbackHolder = DeviceUnlockCallbackHolder()
+ val callback = FakeDeviceUnlockEntryPointCallback()
+ val pinCodeManager = aPinCodeManager().apply {
+ createPinCode("1234")
+ }
+ createDeviceUnlockPresenter(
+ biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
+ callbackHolder = callbackHolder,
+ pinCodeManager = pinCodeManager,
+ ).test {
+ awaitItem().also { state ->
+ assertThat(state.showApplicationPinCode).isFalse()
+ }
+
+ callbackHolder.requestUnlock(callback)
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.showApplicationPinCode).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `present - when unlock requested and no security, unlock immediately`() = runTest {
+ val callbackHolder = DeviceUnlockCallbackHolder()
+ val onUnlockedLambda = lambdaRecorder { }
+ val callback = FakeDeviceUnlockEntryPointCallback(
+ onUnlockedLambda = onUnlockedLambda,
+ )
+ createDeviceUnlockPresenter(
+ biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
+ callbackHolder = callbackHolder,
+ ).test {
+ awaitItem().also { state ->
+ assertThat(state.showApplicationPinCode).isFalse()
+ }
+ callbackHolder.requestUnlock(callback)
+ skipItems(2)
+ assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
+ onUnlockedLambda.assertions().isCalledOnce()
+ }
+ }
+
+ @Test
+ fun `present - CancelPinCode event cancels unlock request`() = runTest {
+ val callbackHolder = DeviceUnlockCallbackHolder()
+ val onCancelLambda = lambdaRecorder { }
+ val callback = FakeDeviceUnlockEntryPointCallback(
+ onCancelLambda = onCancelLambda,
+ )
+ val pinCodeManager = aPinCodeManager().apply {
+ createPinCode("1234")
+ }
+ createDeviceUnlockPresenter(
+ biometricAuthenticatorManager = FakeBiometricAuthenticatorManager(canUseDeviceUnlock = false),
+ callbackHolder = callbackHolder,
+ pinCodeManager = pinCodeManager,
+ ).test {
+ awaitItem()
+ callbackHolder.requestUnlock(callback)
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.showApplicationPinCode).isTrue()
+ state.eventSink(DeviceUnlockEvent.CancelPinCode)
+ }
+ awaitItem().also { state ->
+ assertThat(state.showApplicationPinCode).isFalse()
+ }
+ skipItems(1)
+ onCancelLambda.assertions().isCalledOnce()
+ assertThat(callbackHolder.deviceUnlockCallback.value).isNull()
+ }
+ }
+
+ private fun createDeviceUnlockPresenter(
+ biometricAuthenticatorManager: FakeBiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
+ callbackHolder: DeviceUnlockCallbackHolder = DeviceUnlockCallbackHolder(),
+ pinCodeManager: PinCodeManager = aPinCodeManager(),
+ ): DeviceUnlockPresenter {
+ val pinUnlockHelper = PinUnlockHelper(
+ biometricAuthenticatorManager = biometricAuthenticatorManager,
+ pinCodeManager = pinCodeManager,
+ )
+ return DeviceUnlockPresenter(
+ pinUnlockHelper = pinUnlockHelper,
+ biometricAuthenticatorManager = biometricAuthenticatorManager,
+ deviceUnlockCallbackHolder = callbackHolder,
+ pinCodeManager = pinCodeManager,
+ )
+ }
+}
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..b20a99d27f 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,20 +125,70 @@ 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)
+ }
+ }
+ }
+
+ @Test
+ fun `present - forDeviceUnlock is exposed in state`() = runTest {
+ val presenter = createPinUnlockPresenter(forDeviceUnlock = true)
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ assertThat(state.canNavigateBack).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `present - pin entry changed event updates pin entry`() = runTest {
+ val presenter = createPinUnlockPresenter()
+ presenter.test {
+ skipItems(1)
+ awaitItem().also { state ->
+ state.eventSink(PinUnlockEvent.OnPinEntryChanged(halfCompletePin))
+ }
+ awaitItem().also { state ->
+ state.pinEntry.assertText(halfCompletePin)
+ }
+ }
+ }
+
private fun AsyncData.assertText(text: String) {
dataOrNull()?.assertText(text)
}
private suspend fun TestScope.createPinUnlockPresenter(
+ forDeviceUnlock: Boolean = false,
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)
}
return PinUnlockPresenter(
+ forDeviceUnlock = forDeviceUnlock,
pinCodeManager = pinCodeManager,
biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase,
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..fb7d3aa372 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,55 @@
* 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 io.element.android.tests.testutils.robolectric.RobolectricTest
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()
+class PinKeypadTest : RobolectricTest() {
@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 +113,7 @@ class PinKeypadTest {
)
}
- private fun AndroidComposeTestRule.setPinKeyPad(
+ private fun AndroidComposeUiTest.setPinKeyPad(
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt
new file mode 100644
index 0000000000..8278fc882f
--- /dev/null
+++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPoint.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.lockscreen.test
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakeDeviceUnlockEntryPoint : DeviceUnlockEntryPoint {
+ override fun createNode(
+ parentNode: Node,
+ buildContext: BuildContext,
+ ): Node = lambdaError()
+
+ override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) = lambdaError()
+}
diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPointCallback.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPointCallback.kt
new file mode 100644
index 0000000000..3b7394cae0
--- /dev/null
+++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeDeviceUnlockEntryPointCallback.kt
@@ -0,0 +1,19 @@
+/*
+ * 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.lockscreen.test
+
+import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
+import io.element.android.tests.testutils.EnsureNeverCalled
+
+class FakeDeviceUnlockEntryPointCallback(
+ private val onCancelLambda: () -> Unit = EnsureNeverCalled(),
+ private val onUnlockedLambda: () -> Unit = EnsureNeverCalled(),
+) : DeviceUnlockEntryPoint.Callback {
+ override fun onCancel() = onCancelLambda()
+ override fun onUnlock() = onUnlockedLambda()
+}
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/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
index d3641ea749..feb1d3d53a 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt
@@ -60,6 +60,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.Icon
+import io.element.android.libraries.designsystem.theme.components.PasswordVisibilityToggle
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -249,16 +250,10 @@ private fun LoginForm(
placeholder = stringResource(CommonStrings.common_password),
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
- val image =
- if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
- val description =
- if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
- Box(Modifier.clickable { passwordVisible = !passwordVisible }) {
- Icon(
- imageVector = image,
- contentDescription = description,
- )
- }
+ PasswordVisibilityToggle(
+ visible = passwordVisible,
+ onToggle = { passwordVisible = !passwordVisible },
+ )
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
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/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
index a6d9da3de2..6b5da8f300 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt
@@ -125,7 +125,7 @@ private fun Content(errorScreenType: QrCodeErrorScreenType) {
}
@Composable
-private fun Buttons(
+private fun ColumnScope.Buttons(
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
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..5018377eb0 100644
--- a/features/login/impl/src/main/res/values-be/translations.xml
+++ b/features/login/impl/src/main/res/values-be/translations.xml
@@ -20,15 +20,16 @@
"Які адрас вашага сервера?"
"Выберыце свой сервер"
"Стварыць уліковы запіс"
- "Гэты ўліковы запіс быў дэактываваны."
+ "Гэты ўліковы запіс быў выдалены."
"Няправільнае імя карыстальніка і/або пароль"
"Гэта несапраўдны ідэнтыфікатар карыстальніка. Чаканы фармат: ‘@user:homeserver.org’"
"Гэты сервер настроены на выкарыстанне маркераў абнаўлення. Яны не падтрымліваюцца пры ўваходзе на аснове пароля."
- "Выбраны хатні сервер не падтрымлівае пароль або ўваход у OIDC. Калі ласка, звярніцеся да адміністратара або абярыце іншы хатні сервер."
+ "Выбраны хатні сервер не падтрымлівае пароль або ўваход у OAuth. Калі ласка, звярніцеся да адміністратара або абярыце іншы хатні сервер."
"Увядзіце свае даныя"
"Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі."
"Сардэчна запрашаем!"
"Увайсці ў %1$s"
+ "Версія %1$s"
"Увайсці ўручную"
"Увайсці ў %1$s"
"Увайсці з QR-кодам"
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 fa31796c3b..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,11 +28,11 @@
"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!"
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 284284cc3e..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,16 +28,24 @@
"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"
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 dced91a1c9..89e4412d0f 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -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!"
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 c87027477d..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,15 +28,22 @@
"Ποια είναι η διεύθυνση του διακομιστή σου;"
"Επέλεξε το διακομιστή σου"
"Δημιουργία λογαριασμού"
- "Αυτός ο λογαριασμός έχει απενεργοποιηθεί."
+ "Αυτός ο λογαριασμός έχει διαγραφεί."
"Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης"
"Αυτό δεν είναι έγκυρο αναγνωριστικό χρήστη. Αναμενόμενη μορφή: \'@χρήστης:homeserver.org\'"
"Αυτός ο διακομιστής έχει ρυθμιστεί ώστε να χρησιμοποιεί διακριτικά ανανέωσης. Αυτά δεν υποστηρίζονται όταν χρησιμοποιείς σύνδεση μέσω κωδικού πρόσβασης."
- "Ο επιλεγμένος οικιακός διακομιστής δεν υποστηρίζει κωδικό πρόσβασης ή σύνδεση OIDC. Επικοινωνήστε με τον διαχειριστή σου ή επέλεξε άλλο οικιακό διακομιστή."
+ "Ο επιλεγμένος οικιακός διακομιστής δεν υποστηρίζει κωδικό πρόσβασης ή σύνδεση OAuth. Επικοινωνήστε με τον διαχειριστή σου ή επέλεξε άλλο οικιακό διακομιστή."
"Εισήγαγε τα στοιχεία σου"
"Το Matrix είναι ένα ανοιχτό δίκτυο για ασφαλή, αποκεντρωμένη επικοινωνία."
"Καλωσόρισες ξανά!"
"Συνδέσου στο %1$s"
+ "Άνοιγμα του Element Classic"
+ "Ανοίξτε το Element Classic στη συσκευή σας."
+ "Μεταβείτε στις Ρυθμίσεις > Ασφάλεια και Απόρρητο"
+ "Στη Διαχείριση κλειδιών κρυπτογράφησης, επιλέξτε Ανάκτηση κρυπτογραφημένων μηνυμάτων"
+ "Ακολουθήστε τις οδηγίες για να ενεργοποιήσετε την αποθήκευση κλειδιών"
+ "Επιστρέψτε στο %1$s"
+ "Ενεργοποιήστε την αποθήκευση κλειδιών σας πριν προχωρήσετε στο %1$s"
"Έκδοση %1$s"
"Σύνδεση χειροκίνητα"
"Συνδέσου στο %1$s"
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 7ac4e99694..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,18 +28,24 @@
"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"
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 0d795811b1..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,11 +28,11 @@
"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!"
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 3435eb7d40..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,11 +28,11 @@
"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 !"
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 b52dd77d7f..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,7 +32,7 @@
"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!"
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 c2b2fefddb..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,11 +28,11 @@
"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!"
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 e882654e4b..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,11 +28,11 @@
"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!"
@@ -45,6 +45,7 @@
"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"
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 219605ab22..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,11 +27,11 @@
"サーバーのアドレスは何ですか?"
"サーバーを選択"
"アカウントを作成"
- "このアカウントは無効化されています。"
+ "アカウントは削除されました。"
"ユーザー名またはパスワードが違います"
"無効なユーザーIDです。正しい形式は \"@ユーザー:ホームサーバー\" です。"
"このサーバーはリフレッシュトークンを使用します。パスワードを使用したログインとは併用できません。"
- "指定したホームサーバはパスワードまたはOIDCによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。"
+ "指定したホームサーバはパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。"
"詳細を入力"
"Matrix は安全で分散型のオープンなネットワークです。"
"お待ちしておりました。"
@@ -80,8 +80,8 @@
"%1$s に非対応"
"読み取る"
"コンピュータで %1$s を開く"
- "アバターをタップしてください"
- "%1$s を選択してください"
+ "アバターをタップ"
+ "%1$s を選択"
"\"新しい端末を追加\""
"この端末でQRコードを読み取る"
"アカウント提供元が対応する場合にのみ使用できます。"
@@ -98,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 c870377fc4..8f313b15ee 100644
--- a/features/login/impl/src/main/res/values-ko/translations.xml
+++ b/features/login/impl/src/main/res/values-ko/translations.xml
@@ -28,11 +28,11 @@
"서버의 주소는 무엇인가요?"
"서버 선택"
"계정 만들기"
- "계정이 비활성화되었습니다."
+ "삭제된 계정입니다."
"잘못된 아이디/비밀번호"
"이 사용자 ID는 유효하지 않습니다. 예상 형식: ‘@user:homeserver.org’"
"이 서버는 새로 고침 토큰을 사용하도록 구성되어 있습니다. 비밀번호 기반 로그인을 사용하는 경우 이 기능은 지원되지 않습니다."
- "선택한 홈 서버는 password 또는 OIDC 로그인을 지원하지 않습니다. 관리자에게 문의하거나 다른 홈 서버를 선택하세요."
+ "선택한 홈 서버는 password 또는 OAuth 로그인을 지원하지 않습니다. 관리자에게 문의하거나 다른 홈 서버를 선택하세요."
"귀하의 세부 정보를 입력하십시오"
"Matrix 는 안전하고 분산된 커뮤니케이션을 위한 개방형 네트워크입니다."
"다시 돌아온 걸 환영합니다!"
@@ -45,6 +45,7 @@
"%1$s(으)로 돌아가기"
"%1$s(으)로 진행하기 전에 키 저장소를 활성화해 주세요."
"버전 %1$s"
+ "계정 확인 중"
"수동으로 로그인"
"%1$s 에 로그인합니다"
"QR 코드로 로그인"
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 870e2c4d31..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įžę!"
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 5957b902ce..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!"
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..634d856cdf 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.
@@ -86,7 +97,7 @@ Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu.""Zacznij od nowa"
"Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
"Oczekiwanie na drugie urządzenie"
- "Twój dostawca konta może poprosić o podany kod, aby zweryfikować logowanie."
+ "Twój dostawca konta może poprosić o podany kod w celu weryfikacji logowania."
"Twój kod weryfikacyjny"
"Zmień dostawcę konta"
"Serwer prywatny dla pracowników Element."
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 4437c19056..5afbe1f513 100644
--- a/features/login/impl/src/main/res/values-ru/translations.xml
+++ b/features/login/impl/src/main/res/values-ru/translations.xml
@@ -28,14 +28,14 @@
"Какой адрес у вашего сервера?"
"Выберите свой сервер"
"Создать аккаунт"
- "Данная учётная запись была отключена."
+ "Эта учетная запись была удалена."
"Неверное имя пользователя и/или пароль"
"Это некорректный идентификатор пользователя. Правильный формат: @user:homeserver.org"
"Этот сервер настроен на использование токенов обновления. Они не поддерживаются при использовании входа на основе пароля."
- "Выбранный сервер не поддерживает вход по паролю и OIDC. Пожалуйста, свяжитесь с администратором или выберите другой сервер."
+ "Выбранный сервер не поддерживает вход по паролю и OAuth. Пожалуйста, свяжитесь с администратором или выберите другой сервер."
"Введите свои данные"
"Matrix — это открытая сеть для безопасной децентрализованной связи."
- "Рады видеть вас снова!"
+ "Рады видеть Вас снова!"
"Войти в %1$s"
"Открыть Element Classic"
"Откройте Element Classic на своем устройстве."
@@ -69,9 +69,9 @@
"Запрос на вход отменен"
"Вход в систему был отклонен на другом устройстве."
"Вход отклонен"
- "На этом все."
+ "На этом всё."
"Вход уже выполнен на другом устройстве"
- "Срок действия входа истек. Пожалуйста, попробуйте еще раз."
+ "Срок действия входа истек. Пожалуйста, попробуйте ещё раз."
"Вход в систему не был выполнен вовремя"
"Другое устройство не поддерживает вход в %s с помощью QR-кода.
@@ -95,7 +95,7 @@
"Разрешите доступ к камере для сканирования QR-кода"
"Сканировать QR-код"
"Начать заново"
- "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."
+ "Произошла непредвиденная ошибка. Пожалуйста, попробуйте ещё раз."
"Ожидание другого устройства"
"Сервер для аккаунта может запросить следующий код для подтверждения входа."
"Ваш код подтверждения"
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 07b3d8835b..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,7 +31,7 @@
"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!"
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 4d9b921ea5..66089a4708 100644
--- a/features/login/impl/src/main/res/values-vi/translations.xml
+++ b/features/login/impl/src/main/res/values-vi/translations.xml
@@ -31,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 7170288c5d..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,7 +32,7 @@
"不正確的使用者名稱或密碼"
"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"
"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"
- "選定的家伺服器不支援密碼或 OIDC 登入。請聯絡您的管理員或選擇其他家伺服器。"
+ "選定的家伺服器不支援密碼或 OAuth 登入。請聯絡您的管理員或選擇其他家伺服器。"
"輸入您的詳細資料"
"Matrix 是一個開放網路,為了安全且去中心化的通訊而生。"
"歡迎回來!"
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 ac125ba945..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,10 +1,10 @@
- "更改账户提供方"
+ "更改账户提供者"
"主服务器地址"
"输入搜索词或域名地址。"
"搜索公司、社区或私人服务器。"
- "寻找账户提供方"
+ "查找账户提供者"
"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"
"你即将登录到 %s"
"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"
@@ -12,7 +12,7 @@
"Matrix.org 由 Matrix.org 基金会运营,是用于安全、去中心化的通信的公共 Matrix 网络上的大型免费服务器。"
"其它"
"使用其它账户提供者,例如你自己的私有服务器或工作账户。"
- "更改账户提供方"
+ "更改账户提供者"
"Google Play"
"%1$s 要求 Element Pro。请从应用商店下载。"
"需要 Element Pro"
@@ -21,18 +21,18 @@
%1$s"
"所选账户提供者不支持滑动同步。需要升级服务器才能使用 %1$s。"
"%1$s 不允许连接到 %2$s。"
- "本应用已配置为允许访问:%1$s 。"
+ "此 app 已配置为允许访问:%1$s。"
"账户提供者 %1$s 不被允许。"
"主服务器 URL"
"输入域名地址。"
"你的服务器地址是什么?"
"选择服务器"
"创建账户"
- "该账户已被停用。"
+ "此账户已被删除。"
"用户名与(或)密码不正确"
"这不是合法的用户 ID。预期格式:“@user:homeserver.org”。"
"此服务器使用刷新令牌。使用密码登录时不支持这些功能。"
- "该服务器不支持密码登录与 OIDC 登录。请联系服务器管理员或选择另一服务器。"
+ "该服务器不支持密码登录与 OAuth 登录。请联系服务器管理员或选择另一服务器。"
"输入详细信息"
"Matrix 是一个用于安全、去中心化通信的开放网络。"
"欢迎回来!"
@@ -51,7 +51,7 @@
"使用二维码登录"
"创建账户"
"欢迎回来"
- "欢迎使用 %1$s,快而简约的消息应用。"
+ "欢迎使用迄今最快的 %1$s,速度与简洁的极致。"
"欢迎使用 %1$s,速度与简洁的极致。"
"融入 Element"
"建立安全连接"
@@ -77,7 +77,7 @@
尝试手动或使用另一个设备扫描二维码."
"二维码不受支持"
- "账户提供方不支持 %1$s."
+ "账户提供者不支持 %1$s."
"不支持 %1$s."
"准备进行扫描"
"在桌面设备上打开 %1$s"
@@ -99,7 +99,7 @@
"正在等待其它设备"
"你的账户提供者可能会要求你提供以下代码以验证登录。"
"你的验证码"
- "更改账户提供方"
+ "更改账户提供者"
"专为 Element 员工提供的私人服务器。"
"Matrix 是一个用于安全、去中心化通信的开放网络。"
"这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。"
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index f8d5ffa651..10fb6ef04a 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -32,7 +32,7 @@
"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!"
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/DefaultLoginIntentResolverTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolverTest.kt
index 21b3cca600..feeeb982c4 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolverTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginIntentResolverTest.kt
@@ -8,14 +8,12 @@
package io.element.android.features.login.impl
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-@RunWith(AndroidJUnit4::class)
-class DefaultLoginIntentResolverTest {
+class DefaultLoginIntentResolverTest : RobolectricTest() {
@Test
fun `nominal case`() {
val sut = DefaultLoginIntentResolver()
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..702adaaa6d 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
@@ -31,17 +28,15 @@ import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatib
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-@RunWith(RobolectricTestRunner::class)
-class DefaultElementClassicConnectionTest {
+class DefaultElementClassicConnectionTest : RobolectricTest() {
@Test
fun `connection can be started Element Classic service can be bound`() = runTest {
val connection = createDefaultElementClassicConnection(
@@ -112,21 +107,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 +494,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..c56b4d5208 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
@@ -8,7 +8,6 @@
package io.element.android.features.login.impl.qrcode
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
@@ -22,16 +21,15 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.tests.testutils.lambda.lambdaError
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
-import org.junit.runner.RunWith
-@RunWith(AndroidJUnit4::class)
-class QrCodeLoginFlowNodeTest {
+class QrCodeLoginFlowNodeTest : RobolectricTest() {
@Test
fun `backstack changes when confirmation steps are received`() = runTest {
val qrCodeLoginManager = FakeQrCodeLoginManager()
@@ -79,7 +77,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..b036e07e92 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,19 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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 +27,30 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
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()
-
+class ChooseAccountProviderViewTest : RobolectricTest() {
@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 +60,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 +89,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/classic/ClassicFlowNodeHelperTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt
index 017fd1b633..805e3c10b6 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNodeHelperTest.kt
@@ -10,7 +10,6 @@
package io.element.android.features.login.impl.screens.classic
import androidx.core.graphics.createBitmap
-import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.classic.ElementClassicConnection
@@ -26,16 +25,15 @@ import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
-import org.junit.runner.RunWith
-// Use AndroidJUnit4 for the test with the Bitmap.
-@RunWith(AndroidJUnit4::class)
-class ClassicFlowNodeHelperTest {
+// Use RobolectricTest for the test with the Bitmap.
+class ClassicFlowNodeHelperTest : RobolectricTest() {
@get:Rule
val warmUpRule = WarmUpRule()
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..3a128f2e4f 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,21 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.testtags.TestTags
@@ -30,158 +32,153 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
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()
-
+class LoginPasswordViewTest : RobolectricTest() {
@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/DefaultOnBoardingLogoResIdProviderTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/DefaultOnBoardingLogoResIdProviderTest.kt
index 6e67925444..337f2d659c 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/DefaultOnBoardingLogoResIdProviderTest.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/DefaultOnBoardingLogoResIdProviderTest.kt
@@ -8,14 +8,12 @@
package io.element.android.features.login.impl.screens.onboarding
-import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-@RunWith(AndroidJUnit4::class)
-class DefaultOnBoardingLogoResIdProviderTest {
+class DefaultOnBoardingLogoResIdProviderTest : RobolectricTest() {
@Test
fun `when onboarding_logo resource does not exist, get() returns null`() {
val context = InstrumentationRegistry.getInstrumentation().context
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..52d1667687 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,15 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTestParameter
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()
+class OnboardingViewTest : RobolectricTestParameter() {
@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 +48,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 +91,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 +102,7 @@ class OnboardingViewTest {
),
onSignIn = callback,
)
- rule.clickOn(R.string.screen_onboarding_sign_in_manually)
+ clickOn(R.string.screen_onboarding_sign_in_manually)
}
}
@@ -116,10 +112,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 +124,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 +218,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 +249,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 +257,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 +271,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..8c1802a3c5 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,45 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class QrCodeConfirmationViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class QrCodeConfirmationViewTest : RobolectricTest() {
@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..976d98c594 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,59 +6,55 @@
* 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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class QrCodeErrorViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class QrCodeErrorViewTest : RobolectricTest() {
@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..5720c68ab4 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,12 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.login.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
@@ -19,42 +21,36 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.rules.TestRule
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class QrCodeIntroViewTest {
- @get:Rule
- val rule = createAndroidComposeRule()
+class QrCodeIntroViewTest : RobolectricTest() {
@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 +58,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..c0c7c084a8 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,13 +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.test.ext.junit.runners.AndroidJUnit4
+import androidx.compose.ui.test.AndroidComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
@@ -22,18 +24,12 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
+import io.element.android.tests.testutils.robolectric.RobolectricTest
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()
+class QrCodeScanViewTest : RobolectricTest() {
private var provider: ProcessCameraProvider? = null
@Before
@@ -48,28 +44,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