Merge branch 'release/26.07.0'

This commit is contained in:
Benoit Marty
2026-07-01 10:07:43 +02:00
606 changed files with 4345 additions and 3050 deletions
+4 -4
View File
@@ -44,19 +44,19 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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
+3 -3
View File
@@ -43,7 +43,7 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK
+2 -2
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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:
+6 -5
View File
@@ -1,9 +1,10 @@
name: Generate GitHub Pages
on:
workflow_dispatch:
schedule:
# At 00:00 on every Tuesday UTC
- cron: '0 0 * * 2'
# Disabled for now, as we don't want to generate GitHub Pages on every push to main anymore
# schedule:
# # At 00:00 on every Tuesday UTC
# - cron: '0 0 * * 2'
permissions: {}
@@ -18,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+2 -2
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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:
+4 -4
View File
@@ -38,19 +38,19 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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
+2 -2
View File
@@ -32,11 +32,11 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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'
+5 -5
View File
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21
uses: actions/setup-java@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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis
+26 -26
View File
@@ -33,7 +33,7 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
- uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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:
+2 -2
View File
@@ -54,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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots
+9 -9
View File
@@ -34,16 +34,16 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Create APKs
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+3 -3
View File
@@ -38,19 +38,19 @@ jobs:
docker-images: true
swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
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
+3 -3
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12
+1 -1
View File
@@ -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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python 3.12
+2 -2
View File
@@ -63,12 +63,12 @@ jobs:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
run: git submodule update --init --recursive
- name: ☕️ Use JDK 21
uses: actions/setup-java@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@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+30
View File
@@ -1,3 +1,33 @@
Changes in Element X v26.06.4
=============================
<!-- Release notes generated using configuration in .github/release.yml at 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
=============================
+6
View File
@@ -24,6 +24,7 @@ import extension.buildConfigFieldStr
import extension.locales
import extension.setupDependencyInjection
import extension.testCommonDependencies
import org.sonarqube.gradle.SonarResolverTask
import java.util.Locale
plugins {
@@ -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<SonarResolverTask>().configureEach {
dependsOn("generateGplayDebugResValues", "generateGplayDebugAndroidTestResValues")
}
setupDependencyInjection()
dependencies {
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"通话进行中"</string>
<string name="call_foreground_service_channel_title_android">"正在进行的通话"</string>
<string name="call_foreground_service_message_android">"点击以返回通话"</string>
<string name="call_foreground_service_title_android">"☎️ 通话中"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 Android 版本中使用蓝牙音频设备。请选择其它音频设备。"</string>
@@ -31,6 +31,6 @@
<string name="screen_create_room_space_selection_no_space_option">"Не додавати до простору"</string>
<string name="screen_create_room_space_selection_no_space_title">"Головна"</string>
<string name="screen_create_room_space_selection_sheet_title">"Додати до простору"</string>
<string name="screen_create_room_topic_label">"Тема (необов\'язково)"</string>
<string name="screen_create_room_topic_label">"Тематика обговорень (необовязково)"</string>
<string name="screen_create_room_topic_placeholder">"Додати опис…"</string>
</resources>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Будь ласка, підтвердіть, що ви хочете деактивувати свій обліковий запис. Ця дія не може бути скасована."</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"Підтвердьте, що хочете видалити свій обліковий запис. Цю дію неможливо скасувати."</string>
<string name="screen_deactivate_account_delete_all_messages">"Видалити всі мої повідомлення"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Попередження: майбутні користувачі можуть бачити неповні розмови."</string>
<string name="screen_deactivate_account_description">"Деактивація вашого облікового запису%1$s , це буде:"</string>
<string name="screen_deactivate_account_description">"Видалення вашого облікового запису %1$s призведе до:"</string>
<string name="screen_deactivate_account_description_bold_part">"незворотні"</string>
<string name="screen_deactivate_account_list_item_1">"%1$sваш обліковий запис (ви не можете знову увійти, а ваш ідентифікатор не може бути використаний повторно)."</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"Назавжди відключити"</string>
<string name="screen_deactivate_account_list_item_2">"Видалити вас з усіх чатів."</string>
<string name="screen_deactivate_account_list_item_2">"Вилучити вас з усіх бесід."</string>
<string name="screen_deactivate_account_list_item_3">"Видаліть інформацію свого облікового запису з нашого сервера ідентифікації."</string>
<string name="screen_deactivate_account_list_item_4">"Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."</string>
<string name="screen_deactivate_account_title">"Відключити обліковий запис"</string>
<string name="screen_deactivate_account_title">"Видалити обліковий запис"</string>
</resources>
@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Tidak dapat mengonfirmasi?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Buat kunci pemulihan baru"</string>
<string name="screen_identity_confirmation_subtitle">"Verifikasi perangkat ini untuk menyiapkan perpesanan aman."</string>
<string name="screen_identity_confirmation_subtitle">"Pilih cara verifikasi untuk mengatur pengiriman pesan yang aman."</string>
<string name="screen_identity_confirmation_title">"Konfirmasi bahwa ini Anda"</string>
<string name="screen_identity_confirmation_use_another_device">"Gunakan perangkat lain"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Gunakan kunci pemulihan"</string>
@@ -2,12 +2,12 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Не можете підтвердити?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Створити новий ключ відновлення"</string>
<string name="screen_identity_confirmation_subtitle">"Верифікуйте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string>
<string name="screen_identity_confirmation_title">"Підтвердьте, що це ви"</string>
<string name="screen_identity_confirmation_subtitle">"Виберіть спосіб звіряння, щоб налаштувати безпечний обмін повідомленнями."</string>
<string name="screen_identity_confirmation_title">"Підтвердьте свою цифрову ідентичність"</string>
<string name="screen_identity_confirmation_use_another_device">"Використовуйте інший пристрій"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Використовуйте ключ відновлення"</string>
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</string>
<string name="screen_identity_confirmed_title">"Пристрій перевірено"</string>
<string name="screen_identity_confirmed_title">"Пристрій звірено"</string>
<string name="screen_identity_use_another_device">"Використовуйте інший пристрій"</string>
<string name="screen_identity_waiting_on_other_device">"Чекає на інше пристрій…"</string>
<string name="screen_notification_optin_subtitle">"Ви можете змінити свої налаштування пізніше."</string>
+1
View File
@@ -57,6 +57,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(projects.features.preferences.impl)
implementation(projects.features.reportroom.api)
implementation(projects.features.rolesandpermissions.api)
implementation(projects.libraries.previewutils)
@@ -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(
@@ -198,17 +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())
),
)
}
}
}
@@ -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
}
@@ -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
@@ -42,6 +41,7 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteE
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -52,12 +52,8 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.encryption.RecoveryState
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
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -67,7 +63,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@@ -81,13 +76,11 @@ class RoomListPresenter(
private val roomListDataSource: RoomListDataSource,
private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
private val notificationCleaner: NotificationCleaner,
private val appPreferencesStore: AppPreferencesStore,
private val markRoomAsRead: MarkRoomAsRead,
private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher,
@@ -157,7 +150,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)
}
}
@@ -280,7 +272,6 @@ class RoomListPresenter(
isDm = event.roomSummary.isDm,
isFavorite = event.roomSummary.isFavorite,
hasNewContent = event.roomSummary.hasNewContent,
displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(),
)
contextMenuState.value = initialState
@@ -313,19 +304,10 @@ class RoomListPresenter(
}
private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
client.getRoom(roomId)?.use { room ->
room.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
markRoomAsRead(roomId)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
room.markAsRead(receiptType)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
}
}
private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch {
@@ -336,10 +318,4 @@ class RoomListPresenter(
}
}
}
private fun CoroutineScope.clearCacheOfRoom(roomId: RoomId) = launch {
client.getRoom(roomId)?.use { room ->
room.clearEventCacheStorage()
}
}
}
@@ -44,7 +44,6 @@ data class RoomListState(
val isDm: Boolean,
val isFavorite: Boolean,
val hasNewContent: Boolean,
val displayClearRoomCacheAction: Boolean,
) : ContextMenu
}
@@ -31,5 +31,4 @@ internal fun aContextMenuShown(
isDm = isDm,
hasNewContent = hasNewContent,
isFavorite = isFavorite,
displayClearRoomCacheAction = false,
)
@@ -4,8 +4,8 @@
<string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string>
<string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</string>
<string name="banner_set_up_recovery_content">"Buat kunci pemulihan baru yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi Anda jika Anda kehilangan akses ke perangkat Anda."</string>
<string name="banner_set_up_recovery_submit">"Siapkan pemulihan"</string>
<string name="banner_set_up_recovery_title">"Siapkan pemulihan"</string>
<string name="banner_set_up_recovery_submit">"Siapkan Kunci pemulihan"</string>
<string name="banner_set_up_recovery_title">"Cadangkan obrolan Anda"</string>
<string name="confirm_recovery_key_banner_message">"Konfirmasikan kunci pemulihan Anda untuk mempertahankan akses ke penyimpanan kunci dan riwayat pesan Anda."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Masukkan kunci pemulihan Anda"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Lupa kunci pemulihan Anda?"</string>
@@ -6,7 +6,7 @@
<string name="banner_new_sound_message">"O seu ping de notificação foi atualizado—mais suave, mais rápido, e menos disruptivo."</string>
<string name="banner_new_sound_title">"Recarregamos seus sons"</string>
<string name="banner_set_up_recovery_content">"Recupere sua identidade criptográfica e o histórico de mensagens com uma chave de recuperação caso você perda todos os dispositivos existentes."</string>
<string name="banner_set_up_recovery_submit">"Configurar a recuperação"</string>
<string name="banner_set_up_recovery_submit">"Receber chave de recuperação"</string>
<string name="banner_set_up_recovery_title">"Configure a recuperação para proteger sua conta"</string>
<string name="confirm_recovery_key_banner_message">"Confirme sua chave de recuperação para manter o acesso ao seu armazenamento de chaves e histórico de mensagens."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Digite sua chave de recuperação"</string>
@@ -5,15 +5,15 @@
<string name="banner_battery_optimization_title_android">"Не надходять сповіщення?"</string>
<string name="banner_new_sound_message">"Ваш сигнал сповіщень оновлено — він став чіткішим, швидшим і не таким надокучливим."</string>
<string name="banner_new_sound_title">"Ми оновили ваші звуки"</string>
<string name="banner_set_up_recovery_content">"Відновіть свою криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої."</string>
<string name="banner_set_up_recovery_submit">"Налаштувати відновлення"</string>
<string name="banner_set_up_recovery_title">"Налаштуйте відновлення для захисту свого облікового запису"</string>
<string name="banner_set_up_recovery_content">"Ваші бесіди автоматично резервуються з використанням наскрізного шифрування. Щоб відновити цю резервну копію та зберегти свою цифрову ідентичність у разі втрати доступу до всіх своїх пристроїв, вам знадобиться ключ відновлення."</string>
<string name="banner_set_up_recovery_submit">"Отримати ключ відновлення"</string>
<string name="banner_set_up_recovery_title">"Створити резервну копію своїх бесід"</string>
<string name="confirm_recovery_key_banner_message">"Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Введіть ключ відновлення"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Забули ключ відновлення?"</string>
<string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string>
<string name="full_screen_intent_banner_message">"Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."</string>
<string name="full_screen_intent_banner_title">"Покращуйте досвід дзвінків"</string>
<string name="full_screen_intent_banner_title">"Покращуйте якість викликів"</string>
<string name="screen_home_tab_chats">"Бесіди"</string>
<string name="screen_home_tab_spaces">"Простори"</string>
<string name="screen_invites_decline_chat_message">"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"</string>
@@ -51,6 +51,6 @@
<string name="screen_roomlist_mark_as_unread">"Позначити непрочитаним"</string>
<string name="screen_roomlist_tombstoned_room_description">"Цю кімнату оновлено"</string>
<string name="screen_roomlist_your_spaces">"Ваші простори"</string>
<string name="session_verification_banner_message">"Схоже, ви використовуєте новий пристрій. Щоб отримати доступ до зашифрованих повідомлень, підтвердьте особу за допомогою іншого пристрою."</string>
<string name="session_verification_banner_message">"Здається, ви використовуєте новий пристрій. Звірте його за допомогою іншого пристрою, щоб отримати доступ до своїх зашифрованих повідомлень."</string>
<string name="session_verification_banner_title">"Підтвердьте, що це ви"</string>
</resources>
@@ -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.home.impl.roomlist
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.libraries.matrix.api.core.RoomId
class FakeMarkRoomAsRead(
private val invokeLambda: suspend (RoomId) -> Result<Unit> = { Result.success(Unit) },
) : MarkRoomAsRead {
val invokedRoomIds = mutableListOf<RoomId>()
override suspend fun invoke(roomId: RoomId): Result<Unit> {
invokedRoomIds.add(roomId)
return invokeLambda(roomId)
}
}
@@ -30,6 +30,7 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter
@@ -63,9 +64,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
@@ -230,7 +229,6 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
hasNewContent = false,
displayClearRoomCacheAction = false,
)
)
}
@@ -247,35 +245,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,
)
)
}
@@ -303,7 +272,6 @@ class RoomListPresenterTest {
isDm = false,
isFavorite = false,
hasNewContent = false,
displayClearRoomCacheAction = false,
)
)
@@ -460,11 +428,17 @@ class RoomListPresenterTest {
val notificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomLambda,
)
val markRoomAsRead = createTestMarkRoomAsRead(
client = matrixClient,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
)
val presenter = createRoomListPresenter(
client = matrixClient,
sessionPreferencesStore = sessionPreferencesStore,
analyticsService = analyticsService,
notificationCleaner = notificationCleaner,
markRoomAsRead = markRoomAsRead,
)
presenter.test {
val initialState = awaitItem()
@@ -656,6 +630,24 @@ class RoomListPresenterTest {
}
}
private fun createTestMarkRoomAsRead(
client: MatrixClient,
notificationCleaner: NotificationCleaner,
sessionPreferencesStore: SessionPreferencesStore,
): MarkRoomAsRead = FakeMarkRoomAsRead { roomId ->
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
val room = client.getRoom(roomId) ?: return@FakeMarkRoomAsRead Result.failure(IllegalStateException("Room not found"))
room.use {
it.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
it.markAsRead(receiptType)
}
}
private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@@ -668,10 +660,10 @@ class RoomListPresenterTest {
spaceFiltersPresenter: Presenter<SpaceFiltersState> = Presenter { aDisabledSpaceFiltersState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
@@ -688,15 +680,17 @@ class RoomListPresenterTest {
analyticsService = FakeAnalyticsService(),
),
searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner,
appPreferencesStore = appPreferencesStore,
markRoomAsRead = markRoomAsRead ?: createTestMarkRoomAsRead(
client = client,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
),
seenInvitesStore = seenInvitesStore,
announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
@@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Уже учасник"</string>
<string name="screen_invite_users_already_invited">"Уже запрошені"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Наразі у вас немає чатів із цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Наразі у вас немає чатів із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Наразі у вас немає бесід з цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Наразі у вас немає бесід із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Запросити нових контактів до цієї кімнати?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Запросити нового контакта до цієї кімнати?"</string>
</resources>
@@ -15,6 +15,10 @@
<string name="screen_join_room_fail_reason">"Ruangan ini hanya untuk undangan atau mungkin ada pembatasan akses pada tingkat space."</string>
<string name="screen_join_room_forget_action">"Lupa"</string>
<string name="screen_join_room_invite_required_message">"Anda memerlukan undangan untuk bergabung dalam ruangan ini"</string>
<string name="screen_join_room_invited_by">
"Diundang oleh "
<userName/>
</string>
<string name="screen_join_room_join_action">"Gabung"</string>
<string name="screen_join_room_join_restricted_message">"Anda mungkin perlu diundang atau menjadi anggota space untuk bergabung."</string>
<string name="screen_join_room_knock_action">"Ketuk untuk bergabung"</string>
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Czy na pewno chcesz opuścić tę konwersację? Konwersacja nie jest publiczna i nie będziesz mógł dołączyć ponownie bez zaproszenia."</string>
<string name="leave_room_alert_empty_subtitle">"Jesteś pewien, że chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty."</string>
<string name="leave_room_alert_empty_subtitle">"Czy na pewno chcesz opuścić ten pokój? Jesteś tu jedyną osobą. Jeśli wyjdziesz, nikt nie będzie mógł dołączyć, w tym Ty."</string>
<string name="leave_room_alert_private_subtitle">"Czy na pewno chcesz opuścić ten pokój? Ten pokój nie jest publiczny i nie będziesz mógł do niego wrócić bez zaproszenia."</string>
<string name="leave_room_alert_select_new_owner_action">"Wybierz właścicieli"</string>
<string name="leave_room_alert_select_new_owner_subtitle">"Jesteś jedynym właścicielem tego pokoju. Musisz przenieść własność na kogoś innego, zanim go opuścisz."</string>
<string name="leave_room_alert_select_new_owner_title">"Przenieś własność"</string>
<string name="leave_room_alert_subtitle">"Jesteś pewien, że chcesz wyjść z tego pokoju?"</string>
<string name="leave_room_alert_subtitle">"Czy na pewno chcesz wyjść z tego pokoju?"</string>
</resources>
@@ -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)
@@ -53,6 +54,7 @@ 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)
@@ -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
@@ -34,8 +36,10 @@ 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
@@ -67,11 +71,16 @@ class LinkNewDeviceFlowNode(
private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler,
private val sessionEnterpriseService: SessionEnterpriseService,
private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
navTargets = setOf(NavTarget.LockScreen),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
@@ -124,6 +133,9 @@ class LinkNewDeviceFlowNode(
@Parcelize
data object DesktopScanQrCode : NavTarget
@Parcelize
data object LockScreen : NavTarget
@Parcelize
data class Error(
val errorScreenType: ErrorScreenType,
@@ -137,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()
}
@@ -224,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<LinkNewDeviceRootNode>(buildContext, listOf(callback))
}
is NavTarget.LockScreen -> {
deviceUnlockEntryPoint.createNode(this, buildContext)
}
NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() {
@@ -324,5 +354,6 @@ class LinkNewDeviceFlowNode(
}
}
BackstackView()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
}
}
@@ -43,12 +43,15 @@ class LinkNewMobileHandler(
val stepFlow: StateFlow<LinkMobileStep>
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)
@@ -68,7 +71,7 @@ class LinkNewMobileHandler(
}
fun rotateQrCode() {
createAndStartNewHandler()
createAndStartNewHandler(forRotating = true)
}
fun onTooManyRotation() {
@@ -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<ByteArray>
enum class LinkDeviceType {
Mobile,
Desktop,
}
@@ -8,6 +8,5 @@
package io.element.android.features.linknewdevice.impl.screens.root
sealed interface LinkNewDeviceRootEvent {
data object LinkMobileDevice : LinkNewDeviceRootEvent
data object CloseDialog : LinkNewDeviceRootEvent
}
@@ -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,
)
}
}
@@ -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()
}
@@ -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 = { },
)
}
@@ -13,6 +13,7 @@ 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
@@ -39,6 +40,7 @@ class DefaultLinkNewDeviceEntryPointTest {
linkNewMobileHandler = LinkNewMobileHandler(client),
linkNewDesktopHandler = LinkNewDesktopHandler(client),
sessionEnterpriseService = FakeSessionEnterpriseService(),
deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
)
}
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
@@ -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()
@@ -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()
}
}
@@ -17,9 +17,11 @@ 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 io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
@@ -42,29 +44,31 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
@Test
fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback ->
ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
onLinkDesktopDeviceClick = callback,
onUnlockDevice = callback,
)
clickOn(R.string.screen_link_new_device_root_desktop_computer)
}
}
@Test
fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>()
setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
onUnlockDevice = callback,
)
)
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
@@ -85,13 +89,13 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.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,
)
}
}
@@ -9,6 +9,7 @@
package io.element.android.features.linknewdevice.impl.screens.scan
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
@@ -25,9 +26,11 @@ import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.annotation.Config
class ScanQrCodeViewTest : RobolectricTest() {
@Test
@Config(sdk = [UPSIDE_DOWN_CAKE])
fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false)
ensureCalledOnce { callback ->
@@ -42,6 +45,7 @@ class ScanQrCodeViewTest : RobolectricTest() {
}
@Test
@Config(sdk = [UPSIDE_DOWN_CAKE])
fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>()
setView(
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Votre historique de localisation en direct sera enregistré dans le salon et visible par les membres après la fin de la session."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Choisissez la durée pendant laquelle vous partagerez votre position en direct."</string>
<string name="screen_share_location_live_location_missing_permissions">"Vous n’êtes pas autorisé à partager votre position en direct dans ce salon."</string>
</resources>
@@ -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()
}
}
@@ -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)
@@ -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<DeviceUnlockNode>(
buildContext = buildContext,
)
}
override fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
deviceUnlockCallbackHolder.requestUnlock(callback)
}
}
@@ -59,6 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
override suspend fun setup() {
if (cryptoObject != null) return
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -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 {
@@ -21,6 +21,11 @@ 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)
@@ -35,6 +40,12 @@ interface BiometricAuthenticatorManager {
@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.
*/
@@ -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,6 +85,9 @@ 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 { disable() }
@@ -99,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,
)
}
@@ -114,6 +146,7 @@ class DefaultBiometricAuthenticatorManager(
isAvailable = isAvailable,
promptTitle = promptTitle,
promptNegative = promptNegative,
forDeviceUnlock = false,
)
}
@@ -126,7 +159,8 @@ class DefaultBiometricAuthenticatorManager(
private fun rememberBiometricAuthenticator(
isAvailable: Boolean,
promptTitle: String,
promptNegative: String,
promptNegative: String?,
forDeviceUnlock: Boolean,
): BiometricAuthenticator {
val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) {
@@ -136,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,
@@ -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<DeviceUnlockEntryPoint.Callback?>(null)
val deviceUnlockCallback: StateFlow<DeviceUnlockEntryPoint.Callback?> = _deviceUnlockCallback
fun requestUnlock(callback: DeviceUnlockEntryPoint.Callback) {
_deviceUnlockCallback.value = callback
}
fun onDone() {
_deviceUnlockCallback.value = null
}
}
@@ -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
}
@@ -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<Plugin>,
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,
)
}
}
}
@@ -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<DeviceUnlockState> {
@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,
)
}
}
@@ -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,
)
@@ -103,8 +103,15 @@ class LockScreenSettingsFlowNode(
override fun onUnlock() {
backstack.newRoot(NavTarget.Settings)
}
override fun onCancel() {
// Should not happen in this context.
}
}
createNode<PinUnlockNode>(buildContext, plugins = listOf(callback))
val inputs = PinUnlockNode.Inputs(
forDeviceUnlock = false,
)
createNode<PinUnlockNode>(buildContext, plugins = listOf(callback, inputs))
}
NavTarget.SetupPin -> {
createNode<SetupPinNode>(buildContext)
@@ -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)
@@ -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<Plugin>,
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
)
}
@@ -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<PinUnlockState> {
@AssistedFactory
interface Factory {
fun create(forDeviceUnlock: Boolean): PinUnlockPresenter
}
@Composable
override fun present(): PinUnlockState {
val pinEntryState = remember {
@@ -98,7 +106,7 @@ class PinUnlockPresenter(
}
}
pinUnlockHelper.OnUnlockEffect {
isUnlocked.value = true
isUnlocked.value = it
}
fun handleEvent(event: PinUnlockEvent) {
@@ -128,6 +136,7 @@ class PinUnlockPresenter(
}
}
return PinUnlockState(
canNavigateBack = forDeviceUnlock,
pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
@@ -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<PinEntry>,
val showWrongPinTitle: Boolean,
val remainingAttempts: AsyncData<Int>,
@@ -38,10 +38,20 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
showWrongPinTitle = true,
isUnlocked = true,
),
aPinUnlockState(canNavigateBack = true),
)
}
open class PinUnlockStateCompactProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState>
get() = sequenceOf(
aPinUnlockState(),
aPinUnlockState(canNavigateBack = true)
)
}
fun aPinUnlockState(
canNavigateBack: Boolean = false,
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
showWrongPinTitle: Boolean = false,
@@ -51,6 +61,7 @@ fun aPinUnlockState(
isUnlocked: Boolean = false,
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = PinUnlockState(
canNavigateBack = canNavigateBack,
pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts,
@@ -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,6 +67,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PinUnlockView(
state: PinUnlockState,
isInAppUnlock: Boolean,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
@@ -74,7 +77,7 @@ fun PinUnlockView(
}
}
Surface(modifier) {
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock, onCancel = onCancel)
if (state.showSignOutPrompt) {
SignOutPrompt(
isCancellable = state.isSignOutPromptCancellable,
@@ -105,13 +108,14 @@ fun PinUnlockView(
private fun PinUnlockPage(
state: PinUnlockState,
isInAppUnlock: Boolean,
onCancel: () -> Unit,
) {
BoxWithConstraints {
val commonModifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.padding(all = 20.dp)
.fillMaxSize()
.systemBarsPadding()
.imePadding()
.padding(all = 20.dp)
val header = @Composable {
PinUnlockHeader(
@@ -147,8 +151,8 @@ private fun PinUnlockPage(
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
},
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth()
.focusRequester(focusRequester)
.fillMaxWidth()
)
}
} else {
@@ -177,6 +181,15 @@ private fun PinUnlockPage(
modifier = commonModifier,
)
}
if (state.canNavigateBack) {
BackButton(
onClick = onCancel,
modifier = Modifier
.align(Alignment.TopStart)
.systemBarsPadding()
.padding(8.dp),
)
}
}
}
@@ -217,8 +230,8 @@ private fun PinUnlockCompactView(
}
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
content()
@@ -239,9 +252,9 @@ private fun PinUnlockExpandedView(
header()
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = 40.dp),
.weight(1f)
.fillMaxWidth()
.padding(top = 40.dp),
) {
content()
}
@@ -274,8 +287,8 @@ private fun PinDot(
}
Box(
modifier = Modifier
.size(14.dp)
.background(backgroundColor, CircleShape)
.size(14.dp)
.background(backgroundColor, CircleShape)
)
}
@@ -373,6 +386,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
PinUnlockView(
state = state,
isInAppUnlock = true,
onCancel = {},
)
}
}
@@ -384,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 = {},
)
}
}
@@ -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<PinUnlockBindings>().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
},
)
}
}
@@ -23,7 +23,7 @@ Pilih sesuatu yang mudah untuk diingat. Jika Anda lupa PIN ini, Anda akan dikelu
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Silakan masukkan PIN yang sama dua kali"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN tidak cocok"</string>
<string name="screen_app_lock_signout_alert_message">"Anda harus masuk ulang dan membuat PIN baru untuk melanjutkan"</string>
<string name="screen_app_lock_signout_alert_title">"Anda sedang dikeluarkan"</string>
<string name="screen_app_lock_signout_alert_title">"Perangkat ini sedang dilepas"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="other">"Anda memiliki %1$d percobaan lagi untuk membuka kunci"</item>
</plurals>
@@ -23,7 +23,7 @@ Escolha algo memorável. Se você esquecer este PIN, você será desconectado do
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Por favor, digite o mesmo PIN duas vezes"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Os PINs não correspondem"</string>
<string name="screen_app_lock_signout_alert_message">"Você terá que entrar novamente e criar um PIN novo para continuar"</string>
<string name="screen_app_lock_signout_alert_title">"Você está sendo desconectado"</string>
<string name="screen_app_lock_signout_alert_title">"Este dispositivo está sendo removido"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Você tem %1$d tentativa de desbloqueio"</item>
<item quantity="other">"Você tem %1$d tentativas de desbloqueio"</item>
@@ -34,5 +34,5 @@ Escolha algo memorável. Se você esquecer este PIN, você será desconectado do
</plurals>
<string name="screen_app_lock_use_biometric_android">"Usar biometria"</string>
<string name="screen_app_lock_use_pin_android">"Usar PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</string>
<string name="screen_signout_in_progress_dialog_content">"Removendo dispositivo…"</string>
</resources>
@@ -15,15 +15,15 @@
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Заощаджуйте час і використовуйте %1$s для розблокування застосунку щоразу"</string>
<string name="screen_app_lock_setup_choose_pin">"Виберіть PIN-код"</string>
<string name="screen_app_lock_setup_confirm_pin">"Підтвердити PIN-код"</string>
<string name="screen_app_lock_setup_pin_context">"Заблокуйте %1$s, щоб додати додаткову безпеку вашим чатам.
<string name="screen_app_lock_setup_pin_context">"Додаткове блокування %1$s поліпшить захист ваших бесід.
Виберіть щось, що запам\'ятовується. Але якщо ви забудете PIN-код, ви вийдете з застосунку."</string>
Виберіть щось, що запамятовується. Але якщо ви забудете PIN-код, ви вийдете із застосунку."</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_content">"Ви не можете вибрати його своїм PIN-кодом з міркувань безпеки"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Виберіть інший PIN-код"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Будь ласка, введіть один і той самий PIN-код двічі"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коди не збігаються"</string>
<string name="screen_app_lock_signout_alert_message">"Щоб продовжити, вам потрібно повторно ввійти та створити новий PIN-код"</string>
<string name="screen_app_lock_signout_alert_title">"Ви виходите з системи"</string>
<string name="screen_app_lock_signout_alert_title">"Цей пристрій вилучається"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Ви маєте %1$d спробу"</item>
<item quantity="few">"Ви маєте %1$d спроби"</item>
@@ -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 suspend fun setup() = Unit
override suspend fun authenticate() = authenticateLambda()
override suspend fun setup() = simulateLongTask {
setupLambda()
}
override suspend fun authenticate() = simulateLongTask {
authenticateLambda()
}
}
@@ -12,8 +12,9 @@ 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 {
@@ -28,7 +29,14 @@ class FakeBiometricAuthenticatorManager(
@Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
return remember {
createBiometricAuthenticator()
createBiometricAuthenticator()
}
}
@Composable
override fun rememberUnlockDeviceBiometricAuthenticator(): BiometricAuthenticator {
return remember {
createBiometricAuthenticator()
}
}
@@ -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<Unit> { }
val authenticateLambda = lambdaRecorder<BiometricAuthenticator.AuthenticationResult> {
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<Unit> { }
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<Unit> { }
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,
)
}
}
@@ -147,11 +147,37 @@ class PinUnlockPresenterTest {
}
}
@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<PinEntry>.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 = {}),
@@ -162,6 +188,7 @@ class PinUnlockPresenterTest {
createPinCode(completePin)
}
return PinUnlockPresenter(
forDeviceUnlock = forDeviceUnlock,
pinCodeManager = pinCodeManager,
biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase,
@@ -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()
}
@@ -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()
}
@@ -45,6 +45,7 @@
<string name="screen_missing_key_backup_step_5">"Επιστρέψτε στο %1$s"</string>
<string name="screen_missing_key_backup_title">"Ενεργοποιήστε την αποθήκευση κλειδιών σας πριν προχωρήσετε στο %1$s"</string>
<string name="screen_onboarding_app_version">"Έκδοση %1$s"</string>
<string name="screen_onboarding_checking_account">"Έλεγχος λογαριασμού"</string>
<string name="screen_onboarding_sign_in_manually">"Σύνδεση χειροκίνητα"</string>
<string name="screen_onboarding_sign_in_to">"Συνδέσου στο %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Συνδέσου με κωδικό QR"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Qual é o endereço do seu servidor?"</string>
<string name="screen_change_server_title">"Selecione seu servidor"</string>
<string name="screen_create_account_title">"Criar conta"</string>
<string name="screen_login_error_deactivated_account">"Essa conta foi desativada."</string>
<string name="screen_login_error_deactivated_account">"Essa conta foi apagada."</string>
<string name="screen_login_error_invalid_credentials">"Nome de usuário e/ou senha incorretos"</string>
<string name="screen_login_error_invalid_user_id">"Esse não é um identificador de usuário válido. Formato esperado: \'@usuário:servidor.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Este servidor está configurado para usar tokens recarregados. Não há suporte a eles ao entrar por uma senha."</string>
@@ -51,9 +51,9 @@
<string name="screen_onboarding_sign_in_with_qr_code">"Увійти за допомогою QR-коду"</string>
<string name="screen_onboarding_sign_up">"Створити обліковий запис"</string>
<string name="screen_onboarding_welcome_back">"З поверненням!"</string>
<string name="screen_onboarding_welcome_message">"Ласкаво просимо до найшвидшого %1$s. Заряджений для швидкості та простоти."</string>
<string name="screen_onboarding_welcome_subtitle">"Ласкаво просимо до %1$s. Заряджений, для швидкості та простоти."</string>
<string name="screen_onboarding_welcome_title">"Будьте у своєму element"</string>
<string name="screen_onboarding_welcome_message">"Вітаємо в %1$s. Сфокусованому на швидкості та простоті."</string>
<string name="screen_onboarding_welcome_subtitle">"Вітаємо у %1$s. Сфокусованому на швидкості та простоті."</string>
<string name="screen_onboarding_welcome_title">"Спілкуйтесь елементарно"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Встановлення безпечного з\'єднання"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</string>
@@ -5,14 +5,14 @@
<string name="screen_signout_confirmation_dialog_title">"Hapus device dari akun"</string>
<string name="screen_signout_in_progress_dialog_content">"Mengeluarkan device dari akun…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."</string>
<string name="screen_signout_key_backup_disabled_title">"Anda telah menonaktifkan pencadangan"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Kunci Anda masih dicadangkan saat Anda luring. Sambungkan kembali sehingga kunci Anda dapat dicadangkan sebelum keluar."</string>
<string name="screen_signout_key_backup_disabled_title">"Anda akan segera kehilangan akses ke obrolan terenkripsi Anda."</string>
<string name="screen_signout_key_backup_offline_subtitle">"Kunci Anda masih dalam proses pencadangan saat Anda offline. Sambungkan kembali agar kunci Anda dapat dicadangkan sebelum Anda menghapus perangkat ini."</string>
<string name="screen_signout_key_backup_offline_title">"Kunci Anda masih dicadangkan"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Mohon tunggu hingga proses ini selesai sebelum keluar."</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Harap tunggu hingga proses ini selesai sebelum melepas perangkat ini."</string>
<string name="screen_signout_key_backup_ongoing_title">"Kunci Anda masih dicadangkan"</string>
<string name="screen_signout_preference_item">"Hapus device dari akun"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Anda akan keluar dari sesi Anda yang terakhir. Jika Anda keluar sekarang, Anda akan kehilangan akses ke pesan terenkripsi Anda."</string>
<string name="screen_signout_recovery_disabled_title">"Pemulihan belum disiapkan"</string>
<string name="screen_signout_recovery_disabled_title">"Anda akan segera kehilangan akses ke obrolan terenkripsi Anda."</string>
<string name="screen_signout_save_recovery_key_subtitle">"Anda akan keluar dari sesi terakhir Anda. Jika Anda keluar sekarang, Anda mungkin kehilangan akses ke pesan terenkripsi Anda."</string>
<string name="screen_signout_save_recovery_key_title">"Apakah Anda sudah menyimpan kunci pemulihan Anda?"</string>
<string name="screen_signout_save_recovery_key_title">"Pastikan Anda memiliki akses ke kunci pemulihan sebelum mencabut perangkat ini."</string>
</resources>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Você tem certeza que deseja sair?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sair"</string>
<string name="screen_signout_confirmation_dialog_title">"Sair"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</string>
<string name="screen_signout_confirmation_dialog_content">"Você tem certeza que deseja remover este dispositivo?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Remover este dispositivo"</string>
<string name="screen_signout_confirmation_dialog_title">"Remover este dispositivo"</string>
<string name="screen_signout_in_progress_dialog_content">"Removendo dispositivo…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas."</string>
<string name="screen_signout_key_backup_disabled_title">"Você desativou o backup"</string>
<string name="screen_signout_key_backup_offline_subtitle">"O backup das suas chaves ainda estava sendo feito quando você ficou off-line. Reconecte-se para que você possa fazer o backup de suas chaves antes de sair."</string>
<string name="screen_signout_key_backup_offline_title">"O backup das suas chaves ainda está em andamento"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Aguarde até que isso seja concluído antes de sair."</string>
<string name="screen_signout_key_backup_ongoing_title">"O backup das suas chaves ainda está em andamento"</string>
<string name="screen_signout_preference_item">"Sair"</string>
<string name="screen_signout_preference_item">"Remover este dispositivo"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, perderá o acesso às suas mensagens criptografadas."</string>
<string name="screen_signout_recovery_disabled_title">"A recuperação não está configurada"</string>
<string name="screen_signout_recovery_disabled_title">"Você está prestes a perder o acesso às suas conversas criptografadas"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Você está prestes a sair da sua última sessão. Se você sair agora, poderá perder o acesso às suas mensagens criptografadas."</string>
<string name="screen_signout_save_recovery_key_title">"Você salvou sua chave de recuperação?"</string>
</resources>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Ви впевнені, що хочете видалити цей пристрій?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Вийти"</string>
<string name="screen_signout_confirmation_dialog_title">"Вийти"</string>
<string name="screen_signout_confirmation_dialog_submit">"Вилучити цей пристрій"</string>
<string name="screen_signout_confirmation_dialog_title">"Вилучити цей пристрій"</string>
<string name="screen_signout_in_progress_dialog_content">"Видалення пристрою…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень."</string>
<string name="screen_signout_key_backup_disabled_title">"Ви вимкнули резервне копіювання"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Коли ви вийшли з мережі, резервна копія ваших ключів все ще створювалася. Повторно під\'єднайтеся, щоб зберегти резервну копію ключів перед виходом."</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Це ваш єдиний пристрій. Якщо ви його вилучите, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_key_backup_disabled_title">"Ви втратите доступ до своїх зашифрованих бесід"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Резервні копії ваших ключів продовжували створюватися після вашого виходу з мережі. Під\'єднайтеся знову, щоб створити резервні копії ключів, перш ніж вилучати цей пристрій."</string>
<string name="screen_signout_key_backup_offline_title">"Резервне копіювання ваших ключів ще триває"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Дочекайтеся завершення процесу, перш ніж вийти."</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Дочекайтесь завершення, перш ніж вилучити цей пристрій."</string>
<string name="screen_signout_key_backup_ongoing_title">"Резервне копіювання ваших ключів ще триває"</string>
<string name="screen_signout_preference_item">"Вийти"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень."</string>
<string name="screen_signout_recovery_disabled_title">"Відновлення не налаштовано"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо вийти зараз, ви можете втратити доступ до зашифрованих повідомлень."</string>
<string name="screen_signout_save_recovery_key_title">"Ви зберегли ключ відновлення?"</string>
<string name="screen_signout_preference_item">"Вилучити цей пристрій"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Це ваш єдиний пристрій. Якщо ви вилучите його, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_recovery_disabled_title">"Ви втратите доступ до своїх зашифрованих бесід"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Це ваш єдиний пристрій. Якщо ви його вилучите, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_save_recovery_key_title">"Переконайтеся, що у вас є доступ до ключа відновлення, перш ніж вилучити цей пристрій"</string>
</resources>
@@ -43,6 +43,7 @@ fun TimelineEventTimestampView(
event: TimelineItem.Event,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier,
isLayoutDirectionMismatched: Boolean = false,
) {
val formattedTime = event.sentTime
val hasError = event.failedToSend
@@ -77,9 +78,16 @@ fun TimelineEventTimestampView(
else -> Modifier
}
}
val padding = if (!isLayoutDirectionMismatched) {
PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)
} else {
PaddingValues(end = TimelineEventTimestampViewDefaults.spacing)
}
Row(
modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
.padding(padding)
// For a better click target, make the corners rounded
.clip(RoundedCornerShape(8.dp))
.then(clickableModifier)
@@ -39,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource
@@ -49,9 +51,12 @@ import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
@@ -68,12 +73,14 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.TimelineItemReactions
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
@@ -91,6 +98,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewWithExtraLargeHeight
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
@@ -106,6 +114,7 @@ import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
@@ -122,7 +131,9 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.libraries.ui.utils.text.detect
import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -649,23 +660,44 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
TimestampPosition.Aligned ->
ContentAvoidingLayout(
modifier = modifier,
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
spacing = (-4).dp,
overlayOffset = DpOffset(0.dp, -1.dp),
shrinkContent = canShrinkContent,
content = { content(this::onContentLayoutChange) },
overlay = {
TimelineEventTimestampView(
event = event,
eventSink = eventSink,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
TimestampPosition.Aligned -> @Composable {
val originalLayoutDirection = LocalLayoutDirection.current
// Detect if the direction of the text content (if any) does not match the layout direction, to place the content and timestamp correctly
val contentDirection = if (event.content is TimelineItemTextContent) {
remember(event.content.body) {
when (TextDirection.detect(event.content.body)) {
TextDirection.Ltr, TextDirection.ContentOrLtr -> LayoutDirection.Ltr
TextDirection.Rtl, TextDirection.ContentOrRtl -> LayoutDirection.Rtl
else -> originalLayoutDirection
}
}
)
} else {
originalLayoutDirection
}
CompositionLocalProvider(LocalLayoutDirection provides contentDirection) {
ContentAvoidingLayout(
modifier = modifier,
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
spacing = (-4).dp,
overlayOffset = DpOffset(0.dp, -1.dp),
shrinkContent = canShrinkContent,
content = { content(this::onContentLayoutChange) },
overlay = {
// Use the original layout direction for the timestamp
CompositionLocalProvider(LocalLayoutDirection provides originalLayoutDirection) {
TimelineEventTimestampView(
event = event,
eventSink = eventSink,
isLayoutDirectionMismatched = originalLayoutDirection != contentDirection,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
)
}
}
TimestampPosition.Below ->
Column(modifier) {
content {}
@@ -879,6 +911,149 @@ internal fun TimelineItemEventRowWithThreadSummaryPreview() = ElementPreview {
}
}
@PreviewWithExtraLargeHeight
@Composable
internal fun TimelineItemEventRowRtlContentPreview() = ElementPreview {
Column {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
text = "LTR layout direction",
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.First,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَ",
isEdited = true,
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع \nيَسْتَغْرِب فِيهَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَا اَلشَّاعِر أَنْ لَا يَبْكِي مِنْ أَلَم اَلْفِرَاق،" +
" وَيَصِف حَالَة اَلْمُودِعِينَ"
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(8.dp),
text = "RTL layout direction",
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontHeadingMdBold,
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
sequenceOf(false, true).forEach { isMine ->
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "ظَة وَدَاع يَسْتَغْرِب فِيهَا اَلشَّاعِر أَنْ لَا يَبْكِي مِنْ أَلَم اَلْفِرَاق،" +
" وَيَصِف حَالَة اَلْمُودِعِينَ",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.First,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing\nLTR Line\nBreaks.",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing a very long LTR text in an RTL layout."
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Middle,
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing LTR in RTL layout.",
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
ATimelineItemEventRow(
event = aTimelineItemEvent(
senderDisplayName = "Sender with a super long name that should ellipsize",
isMine = isMine,
content = aTimelineItemTextContent(
body = "Testing LTR in RTL layout.",
isEdited = true,
),
timelineItemReactions = TimelineItemReactions(persistentListOf()),
groupPosition = TimelineItemGroupPosition.Last,
messageShield = MessageShield.UnknownDevice(isCritical = true),
)
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun ThreadSummaryViewPreview() {
@@ -15,13 +15,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.wysiwyg.compose.EditorStyledText
import kotlin.math.max
@@ -149,13 +149,13 @@ object ContentAvoidingLayout {
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp,
): ((TextLayoutResult) -> Unit) {
val layoutDirection = LocalLayoutDirection.current
val extraWidthPx = extraWidth.roundToPx()
return { textLayout: TextLayoutResult ->
// We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = when (layoutDirection) {
LayoutDirection.Ltr -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
val textDirection = tryOrNull { textLayout.getParagraphDirection(0) }
val lastLineWidth = when (textDirection) {
ResolvedTextDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
else -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
}
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
onContentLayoutChange(
@@ -16,10 +16,18 @@
<string name="emoji_picker_category_places">"Voyages &amp; lieux"</string>
<string name="emoji_picker_category_recent">"Emojis récents"</string>
<string name="emoji_picker_category_symbols">"Symboles"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner limage horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Retournée horizontalement"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Retourner limage verticalement"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Retournée verticalement"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Originale"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Pivoter limage vers la gauche"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degré"</item>
<item quantity="other">"%1$d degrés"</item>
</plurals>
<string name="screen_image_edition_title">"Modifier la photo"</string>
<string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs danciennes applications."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Cliquez pour modifier la qualité denvoi de la vidéo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier na pas pu être envoyé."</string>
@@ -30,6 +38,7 @@
<string name="screen_media_upload_preview_item_count">"Élément %1$d sur %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiser la qualité de limage"</string>
<string name="screen_media_upload_preview_processing">"Traitement en cours…"</string>
<string name="screen_media_upload_preview_title">"Ajouter un média"</string>
<string name="screen_report_content_block_user">"Bloquer lutilisateur"</string>
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
<string name="screen_report_content_explanation">"Ce message sera signalé à ladministrateur de votre serveur daccueil. Il ne pourra lire aucun message chiffré."</string>
@@ -23,6 +23,7 @@
<string name="screen_media_upload_preview_error_failed_sending">"Gagal mengunggah media, silakan coba lagi."</string>
<string name="screen_media_upload_preview_error_too_large_message">"Ukuran file maksimum yang diizinkan adalah%1$s ."</string>
<string name="screen_media_upload_preview_error_too_large_title">"Ukuran file terlalu besar untuk diunggah."</string>
<string name="screen_media_upload_preview_item_count">"Item %1$d dari %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimalkan kualitas gambar"</string>
<string name="screen_media_upload_preview_processing">"Memproses…"</string>
<string name="screen_report_content_block_user">"Blokir pengguna"</string>
@@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Gravar vídeo"</string>
<string name="screen_room_attachment_source_files">"Anexo"</string>
<string name="screen_room_attachment_source_gallery">"Biblioteca de fotos e vídeos"</string>
<string name="screen_room_attachment_source_location">"Localização"</string>
<string name="screen_room_attachment_source_location">"Compartilhar localização"</string>
<string name="screen_room_attachment_source_poll">"Enquete"</string>
<string name="screen_room_attachment_text_formatting">"Formatação de texto"</string>
<string name="screen_room_encrypted_history_banner">"O histórico de mensagens não está disponível no momento."</string>
@@ -2,11 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_mismatched_sender">"Відправник події не збігається з власником пристрою, який його надіслав."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Автентичність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої."</string>
<string name="crypto_event_authenticity_previously_verified">"Зашифровано попередньо перевіреним користувачем."</string>
<string name="crypto_event_authenticity_previously_verified">"Зашифровано звіреним раніше користувачем."</string>
<string name="crypto_event_authenticity_sent_in_clear">"Не зашифровано."</string>
<string name="crypto_event_authenticity_unknown_device">"Зашифровано невідомим або видаленим пристроєм."</string>
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не верифіковано його власником."</string>
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано неверифікованим користувачем."</string>
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не звірено його власником."</string>
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано не звіреним користувачем."</string>
<string name="emoji_picker_category_activity">"Дії"</string>
<string name="emoji_picker_category_flags">"Прапори"</string>
<string name="emoji_picker_category_foods">"Їжа та напої"</string>
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
<string name="emoji_picker_category_recent">"Нещодавні емодзі"</string>
<string name="emoji_picker_category_symbols">"Символи"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Повернути зображення по горизонталі"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Перевернуто по горизонталі"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Оригінал"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Повернути зображення по вертикалі"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Перевернуто по вертикалі"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Оригінал"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Повернути зображення вліво"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d градус"</item>
@@ -47,7 +53,7 @@
<string name="screen_room_attachment_source_poll">"Опитування"</string>
<string name="screen_room_attachment_text_formatting">"Форматування тексту"</string>
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Історія повідомлень недоступна в цій кімнаті. Перевірте цей пристрій, щоб побачити історію повідомлень."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Історія повідомлень недоступна в цій кімнаті. Звірте цей пристрій, щоб побачити історію повідомлень."</string>
<string name="screen_room_invite_again_alert_message">"Чи хотіли б ви запросити їх знову?"</string>
<string name="screen_room_invite_again_alert_title">"Ви одні в цій бесіді"</string>
<string name="screen_room_mentions_at_room_subtitle">"Сповістити всю кімнату"</string>
@@ -15,4 +15,6 @@ sealed interface DeveloperSettingsEvents {
data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents
data object VacuumStores : DeveloperSettingsEvents
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvents
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvents
}
@@ -22,6 +22,7 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction
@@ -46,6 +47,7 @@ class DeveloperSettingsPresenter(
private val vacuumStoresUseCase: VacuumStoresUseCase,
private val databaseSizesUseCase: GetDatabaseSizesUseCase,
private val fileSizeFormatter: FileSizeFormatter,
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
) : Presenter<DeveloperSettingsState> {
@Composable
override fun present(): DeveloperSettingsState {
@@ -58,6 +60,9 @@ class DeveloperSettingsPresenter(
val clearCacheAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val markAllRoomsAsReadAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
var showColorPicker by remember {
mutableStateOf(false)
}
@@ -88,6 +93,18 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
vacuumStoresUseCase()
}
is DeveloperSettingsEvents.MarkAllRoomsAsRead -> {
if (event.needsConfirmation) {
markAllRoomsAsReadAction.value = AsyncAction.ConfirmingNoParams
} else {
coroutineScope.markAllRoomsAsRead(
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
)
}
}
DeveloperSettingsEvents.DismissMarkAllRoomsAsReadConfirmation -> {
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
}
}
}
@@ -97,6 +114,7 @@ class DeveloperSettingsPresenter(
cacheSize = cacheSize.value,
databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
showColorPicker = showColorPicker,
eventSink = ::handleEvent,
@@ -131,8 +149,14 @@ class DeveloperSettingsPresenter(
}
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch {
suspend { clearCacheUseCase() }.runCatchingUpdatingState(state = clearCacheAction)
}
private fun CoroutineScope.markAllRoomsAsRead(
markAllRoomsAsReadAction: MutableState<AsyncAction<Unit>>,
) = launch {
suspend {
clearCacheUseCase()
}.runCatchingUpdatingState(clearCacheAction)
markAllRoomsAsRead().getOrThrow()
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
}
}
@@ -18,9 +18,10 @@ data class DeveloperSettingsState(
val cacheSize: AsyncData<String>,
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
val clearCacheAction: AsyncAction<Unit>,
val markAllRoomsAsReadAction: AsyncAction<Unit>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit
) {
val showLoader = clearCacheAction is AsyncAction.Loading
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
}
@@ -37,6 +37,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSe
fun aDeveloperSettingsState(
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
eventSink: (DeveloperSettingsEvents) -> Unit = {},
@@ -45,6 +46,7 @@ fun aDeveloperSettingsState(
cacheSize = AsyncData.Success("1.2 MB"),
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
eventSink = eventSink,
@@ -18,9 +18,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -45,6 +47,15 @@ fun DeveloperSettingsView(
if (state.showLoader) {
ProgressDialog()
}
if (state.markAllRoomsAsReadAction.isConfirming()) {
ConfirmationDialog(
title = "Are you sure you want to mark all the rooms as read?",
content = "",
submitText = stringResource(CommonStrings.action_yes),
onSubmitClick = { state.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = false)) },
onDismiss = { state.eventSink(DeveloperSettingsEvents.DismissMarkAllRoomsAsReadConfirmation) },
)
}
BackHandler(
enabled = !state.showLoader,
onBack = onBackClick,
@@ -64,6 +75,7 @@ fun DeveloperSettingsView(
onOpenShowkase = onOpenShowkase,
)
NotificationCategory(onPushHistoryClick)
MarkAllRoomsAsReadCategory(state)
if (state.isEnterpriseBuild) {
PreferenceCategory(title = "Theme") {
@@ -152,6 +164,32 @@ fun DeveloperSettingsView(
)
}
@Composable
private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
PreferenceCategory(title = "Room list") {
ListItem(
headlineContent = {
Text("Mark all rooms as read")
},
supportingContent = {
Text(
text = """
This will send a private read receipt and a read marker in every room you are part of.
It's a long running operation that might get rate limited.
It will run in the background but the app must be alive for it to finish.
""".trimIndent(),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
},
enabled = !state.showLoader,
onClick = {
state.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = true))
},
)
}
}
@Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
@@ -0,0 +1,33 @@
/*
* 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.preferences.impl.tasks
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import kotlinx.coroutines.withContext
interface MarkAllRoomsAsRead {
suspend operator fun invoke(): Result<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultMarkAllRoomsAsRead(
private val client: MatrixClient,
private val notificationCleaner: NotificationCleaner,
private val coroutineDispatchers: CoroutineDispatchers,
) : MarkAllRoomsAsRead {
override suspend fun invoke(): Result<Unit> = withContext(coroutineDispatchers.io) {
client.markAllRoomsAsRead()
.onSuccess {
notificationCleaner.clearAllMessagesEvents(client.sessionId)
}
}
}
@@ -0,0 +1,45 @@
/*
* 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.preferences.impl.tasks
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
interface MarkRoomAsRead {
suspend operator fun invoke(roomId: RoomId): Result<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultMarkRoomAsRead(
private val client: MatrixClient,
private val notificationCleaner: NotificationCleaner,
private val sessionPreferencesStore: SessionPreferencesStore,
private val coroutineDispatchers: CoroutineDispatchers,
) : MarkRoomAsRead {
override suspend fun invoke(roomId: RoomId): Result<Unit> = withContext(coroutineDispatchers.io) {
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId)
val room = client.getRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("Room not found"))
room.use {
it.setUnreadFlag(isUnread = false)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
it.markAsRead(receiptType)
}
}
}
@@ -96,33 +96,7 @@ Kui sa jätkad muutmist, siis võivad muutuda ka need peidetud eelistused."</str
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Sulge helinavea viga"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"Viga helina seadistamisel"</string>
<string name="screen_notification_settings_sound_silent">"Vaikne"</string>
<string name="screen_notification_settings_sound_system_alert">"Hoiatus"</string>
<string name="screen_notification_settings_sound_system_anticipate">"Ootus"</string>
<string name="screen_notification_settings_sound_system_bell">"Kelluke"</string>
<string name="screen_notification_settings_sound_system_bloom">"Õitsemine"</string>
<string name="screen_notification_settings_sound_system_calypso">"Kalüpso"</string>
<string name="screen_notification_settings_sound_system_chime">"Kellamäng"</string>
<string name="screen_notification_settings_sound_system_choo_choo">"Tšuhh-tšuhh"</string>
<string name="screen_notification_settings_sound_system_default">"Süsteemi vaikimisi väärtus"</string>
<string name="screen_notification_settings_sound_system_descent">"Laskumine"</string>
<string name="screen_notification_settings_sound_system_electronic">"Elektrooniline"</string>
<string name="screen_notification_settings_sound_system_fanfare">"Tuututamine"</string>
<string name="screen_notification_settings_sound_system_glass">"Klaas"</string>
<string name="screen_notification_settings_sound_system_horn">"Pasun"</string>
<string name="screen_notification_settings_sound_system_ladder">"Redel"</string>
<string name="screen_notification_settings_sound_system_minuet">"Menuett"</string>
<string name="screen_notification_settings_sound_system_news_flash">"Uudisvälgatus"</string>
<string name="screen_notification_settings_sound_system_noir">"Sünge"</string>
<string name="screen_notification_settings_sound_system_sherwood_forest">"Sherwoodi mets"</string>
<string name="screen_notification_settings_sound_system_spell">"Loits"</string>
<string name="screen_notification_settings_sound_system_suspense">"Põnevus"</string>
<string name="screen_notification_settings_sound_system_swish">"Vihin-vuhin"</string>
<string name="screen_notification_settings_sound_system_telegraph">"Telegraaf"</string>
<string name="screen_notification_settings_sound_system_tiptoes">"Kikivarvul"</string>
<string name="screen_notification_settings_sound_system_tri_tone">"Kolmetooniline"</string>
<string name="screen_notification_settings_sound_system_tweet">"Säuts"</string>
<string name="screen_notification_settings_sound_system_typewriters">"Kirjutusmasinad"</string>
<string name="screen_notification_settings_sound_system_update">"Uuenda"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Teavituste saamiseks palun muuda oma %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"süsteemi seadistusi"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Süsteemi teavitused on välja lülitatud"</string>

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