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 docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APKs - name: Assemble debug APKs
@@ -80,7 +80,7 @@ jobs:
path: | path: |
app/build/outputs/apk/gplay/debug/*-universal-debug.apk app/build/outputs/apk/gplay/debug/*-universal-debug.apk
app/build/outputs/apk/fdroid/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 id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails
continue-on-error: true continue-on-error: true
+3 -3
View File
@@ -43,7 +43,7 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
@@ -56,12 +56,12 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - 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 # 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' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
@@ -24,7 +24,7 @@ jobs:
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5 uses: danger/danger-js@58355dc2b274a87699c8b7deac01baba16d6f061 # 13.0.8
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:
+6 -5
View File
@@ -1,9 +1,10 @@
name: Generate GitHub Pages name: Generate GitHub Pages
on: on:
workflow_dispatch: workflow_dispatch:
schedule: # Disabled for now, as we don't want to generate GitHub Pages on every push to main anymore
# At 00:00 on every Tuesday UTC # schedule:
- cron: '0 0 * * 2' # # At 00:00 on every Tuesday UTC
# - cron: '0 0 * * 2'
permissions: {} permissions: {}
@@ -18,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - name: Set up Python 3.12
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
name: Use JDK 21 name: Use JDK 21
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
+4 -4
View File
@@ -38,19 +38,19 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.ref }} ref: ${{ github.ref }}
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
name: Use JDK 21 name: Use JDK 21
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK
@@ -78,7 +78,7 @@ jobs:
concurrency: concurrency:
group: maestro-test group: maestro-test
steps: 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' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # 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 docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
+5 -5
View File
@@ -37,13 +37,13 @@ jobs:
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: false cache-read-only: false
@@ -76,16 +76,16 @@ jobs:
name: Dependency analysis name: Dependency analysis
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis
+26 -26
View File
@@ -33,7 +33,7 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
@@ -51,7 +51,7 @@ jobs:
name: Search for invalid screenshot files name: Search for invalid screenshot files
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 3.12 - name: Set up Python 3.12
@@ -65,16 +65,16 @@ jobs:
name: Search for invalid dependencies name: Search for invalid dependencies
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Konsist tests - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run compose tests - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build Gplay Debug - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # 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' }} 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 run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - 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) }} 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 cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
@@ -319,7 +319,7 @@ jobs:
name: Check shell scripts name: Check shell scripts
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run shellcheck - name: Run shellcheck
@@ -333,10 +333,10 @@ jobs:
permissions: permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
upload_reports: upload_reports:
name: Project Check Suite name: Project Check Suite
@@ -344,7 +344,7 @@ jobs:
needs: [konsist, lint, ktlint, detekt] needs: [konsist, lint, ktlint, detekt]
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
@@ -360,7 +360,7 @@ jobs:
yarn add danger-plugin-lint-report --dev yarn add danger-plugin-lint-report --dev
- name: Danger lint - name: Danger lint
if: always() if: always()
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5 uses: danger/danger-js@58355dc2b274a87699c8b7deac01baba16d6f061 # 13.0.8
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env: env:
+2 -2
View File
@@ -54,13 +54,13 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots
+9 -9
View File
@@ -34,16 +34,16 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - 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 - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -71,7 +71,7 @@ jobs:
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }} group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Add SSH private keys for submodule repositories - name: Add SSH private keys for submodule repositories
@@ -82,12 +82,12 @@ jobs:
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive run: git submodule update --init --recursive
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - 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 - name: Create Enterprise app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
@@ -122,16 +122,16 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Create APKs - name: Create APKs
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
+3 -3
View File
@@ -38,19 +38,19 @@ jobs:
docker-images: true docker-images: true
swap-storage: false swap-storage: false
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build debug code and test fixtures - name: Build debug code and test fixtures
+3 -3
View File
@@ -13,16 +13,16 @@ jobs:
# Skip in forks # Skip in forks
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.12 - 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' }} 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. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python 3.12 - 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' }} 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 run: git submodule update --init --recursive
- name: ☕️ Use JDK 21 - name: ☕️ Use JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with: with:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '21' java-version: '21'
- name: Configure gradle - name: Configure gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6.1.1 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} 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 Changes in Element X v26.06.3
============================= =============================
+6
View File
@@ -24,6 +24,7 @@ import extension.buildConfigFieldStr
import extension.locales import extension.locales
import extension.setupDependencyInjection import extension.setupDependencyInjection
import extension.testCommonDependencies import extension.testCommonDependencies
import org.sonarqube.gradle.SonarResolverTask
import java.util.Locale import java.util.Locale
plugins { plugins {
@@ -245,6 +246,11 @@ androidComponents {
configureLicensesTasks(reportingExtension) 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() setupDependencyInjection()
dependencies { 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_message_android">"点击以返回通话"</string>
<string name="call_foreground_service_title_android">"☎️ 通话中"</string> <string name="call_foreground_service_title_android">"☎️ 通话中"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call 不支持在此 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_option">"Не додавати до простору"</string>
<string name="screen_create_room_space_selection_no_space_title">"Головна"</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_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> <string name="screen_create_room_topic_placeholder">"Додати опис…"</string>
</resources> </resources>
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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">"Видалити всі мої повідомлення"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Попередження: майбутні користувачі можуть бачити неповні розмови."</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_description_bold_part">"незворотні"</string>
<string name="screen_deactivate_account_list_item_1">"%1$sваш обліковий запис (ви не можете знову увійти, а ваш ідентифікатор не може бути використаний повторно)."</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_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_3">"Видаліть інформацію свого облікового запису з нашого сервера ідентифікації."</string>
<string name="screen_deactivate_account_list_item_4">"Ваші повідомлення залишатимуться видимими для зареєстрованих користувачів, але недоступними для нових або незареєстрованих користувачів, якщо ви вирішите їх видалити."</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> </resources>
@@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_title">"Konfirmasi bahwa ini Anda"</string>
<string name="screen_identity_confirmation_use_another_device">"Gunakan perangkat lain"</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> <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"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Не можете підтвердити?"</string> <string name="screen_identity_confirmation_cannot_confirm">"Не можете підтвердити?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Створити новий ключ відновлення"</string> <string name="screen_identity_confirmation_create_new_recovery_key">"Створити новий ключ відновлення"</string>
<string name="screen_identity_confirmation_subtitle">"Верифікуйте цей пристрій, щоб налаштувати безпечний обмін повідомленнями."</string> <string name="screen_identity_confirmation_subtitle">"Виберіть спосіб звіряння, щоб налаштувати безпечний обмін повідомленнями."</string>
<string name="screen_identity_confirmation_title">"Підтвердьте, що це ви"</string> <string name="screen_identity_confirmation_title">"Підтвердьте свою цифрову ідентичність"</string>
<string name="screen_identity_confirmation_use_another_device">"Використовуйте інший пристрій"</string> <string name="screen_identity_confirmation_use_another_device">"Використовуйте інший пристрій"</string>
<string name="screen_identity_confirmation_use_recovery_key">"Використовуйте ключ відновлення"</string> <string name="screen_identity_confirmation_use_recovery_key">"Використовуйте ключ відновлення"</string>
<string name="screen_identity_confirmed_subtitle">"Тепер ви можете безпечно читати або надсилати повідомлення, і кожен, з ким ви спілкуєтесь, також може довіряти цьому пристрою."</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_use_another_device">"Використовуйте інший пристрій"</string>
<string name="screen_identity_waiting_on_other_device">"Чекає на інше пристрій…"</string> <string name="screen_identity_waiting_on_other_device">"Чекає на інше пристрій…"</string>
<string name="screen_notification_optin_subtitle">"Ви можете змінити свої налаштування пізніше."</string> <string name="screen_notification_optin_subtitle">"Ви можете змінити свої налаштування пізніше."</string>
+1
View File
@@ -57,6 +57,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(projects.features.preferences.impl)
implementation(projects.features.reportroom.api) implementation(projects.features.reportroom.api)
implementation(projects.features.rolesandpermissions.api) implementation(projects.features.rolesandpermissions.api)
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
@@ -69,10 +69,6 @@ fun RoomListContextMenu(
onFavoriteChange = { isFavorite -> onFavoriteChange = { isFavorite ->
eventSink(RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, isFavorite)) eventSink(RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
}, },
onClearCacheRoomClick = {
eventSink(RoomListEvent.HideContextMenu)
eventSink(RoomListEvent.ClearCacheOfRoom(contextMenu.roomId))
},
onReportRoomClick = { onReportRoomClick = {
eventSink(RoomListEvent.HideContextMenu) eventSink(RoomListEvent.HideContextMenu)
onReportRoomClick(contextMenu.roomId) onReportRoomClick(contextMenu.roomId)
@@ -90,7 +86,6 @@ private fun RoomListModalBottomSheetContent(
onFavoriteChange: (isFavorite: Boolean) -> Unit, onFavoriteChange: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit, onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit, onRoomMarkUnreadClick: () -> Unit,
onClearCacheRoomClick: () -> Unit,
onReportRoomClick: () -> Unit, onReportRoomClick: () -> Unit,
) { ) {
Column( Column(
@@ -198,17 +193,6 @@ private fun RoomListModalBottomSheetContent(
), ),
style = ListItemStyle.Destructive, 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 MarkAsRead(val roomId: RoomId) : ContextMenuEvent
data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvent data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvent
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : 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.Announcement
import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.home.impl.datasource.RoomListDataSource 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.RoomListFiltersState
import io.element.android.features.home.impl.filters.into import io.element.android.features.home.impl.filters.into
import io.element.android.features.home.impl.search.RoomListSearchEvent 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.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState 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.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomList 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.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.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.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.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -67,7 +63,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -81,13 +76,11 @@ class RoomListPresenter(
private val roomListDataSource: RoomListDataSource, private val roomListDataSource: RoomListDataSource,
private val filtersPresenter: Presenter<RoomListFiltersState>, private val filtersPresenter: Presenter<RoomListFiltersState>,
private val searchPresenter: Presenter<RoomListSearchState>, private val searchPresenter: Presenter<RoomListSearchState>,
private val sessionPreferencesStore: SessionPreferencesStore,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>, private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>, private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
private val notificationCleaner: NotificationCleaner, private val markRoomAsRead: MarkRoomAsRead,
private val appPreferencesStore: AppPreferencesStore,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val coldStartWatcher: AnalyticsColdStartWatcher, private val coldStartWatcher: AnalyticsColdStartWatcher,
@@ -157,7 +150,6 @@ class RoomListPresenter(
} }
is RoomListEvent.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary) is RoomListEvent.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvent.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden RoomListEvent.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
is RoomListEvent.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
} }
} }
@@ -280,7 +272,6 @@ class RoomListPresenter(
isDm = event.roomSummary.isDm, isDm = event.roomSummary.isDm,
isFavorite = event.roomSummary.isFavorite, isFavorite = event.roomSummary.isFavorite,
hasNewContent = event.roomSummary.hasNewContent, hasNewContent = event.roomSummary.hasNewContent,
displayClearRoomCacheAction = appPreferencesStore.isDeveloperModeEnabledFlow().first(),
) )
contextMenuState.value = initialState contextMenuState.value = initialState
@@ -313,19 +304,10 @@ class RoomListPresenter(
} }
private fun CoroutineScope.markAsRead(roomId: RoomId) = launch { private fun CoroutineScope.markAsRead(roomId: RoomId) = launch {
notificationCleaner.clearMessagesForRoom(client.sessionId, roomId) markRoomAsRead(roomId)
client.getRoom(roomId)?.use { room -> .onSuccess {
room.setUnreadFlag(isUnread = false) analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
} }
room.markAsRead(receiptType)
.onSuccess {
analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle)
}
}
} }
private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch { 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 isDm: Boolean,
val isFavorite: Boolean, val isFavorite: Boolean,
val hasNewContent: Boolean, val hasNewContent: Boolean,
val displayClearRoomCacheAction: Boolean,
) : ContextMenu ) : ContextMenu
} }
@@ -31,5 +31,4 @@ internal fun aContextMenuShown(
isDm = isDm, isDm = isDm,
hasNewContent = hasNewContent, hasNewContent = hasNewContent,
isFavorite = isFavorite, isFavorite = isFavorite,
displayClearRoomCacheAction = false,
) )
@@ -4,8 +4,8 @@
<string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string> <string name="banner_battery_optimization_submit_android">"Nonaktifkan optimasi"</string>
<string name="banner_battery_optimization_title_android">"Notifikasi tidak masuk?"</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_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_submit">"Siapkan Kunci pemulihan"</string>
<string name="banner_set_up_recovery_title">"Siapkan 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_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_primary_button_title">"Masukkan kunci pemulihan Anda"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Lupa 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_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_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_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="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_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> <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_battery_optimization_title_android">"Не надходять сповіщення?"</string>
<string name="banner_new_sound_message">"Ваш сигнал сповіщень оновлено — він став чіткішим, швидшим і не таким надокучливим."</string> <string name="banner_new_sound_message">"Ваш сигнал сповіщень оновлено — він став чіткішим, швидшим і не таким надокучливим."</string>
<string name="banner_new_sound_title">"Ми оновили ваші звуки"</string> <string name="banner_new_sound_title">"Ми оновили ваші звуки"</string>
<string name="banner_set_up_recovery_content">"Відновіть свою криптографічну ідентичність та історію повідомлень за допомогою ключа відновлення, якщо ви втратили всі наявні пристрої."</string> <string name="banner_set_up_recovery_content">"Ваші бесіди автоматично резервуються з використанням наскрізного шифрування. Щоб відновити цю резервну копію та зберегти свою цифрову ідентичність у разі втрати доступу до всіх своїх пристроїв, вам знадобиться ключ відновлення."</string>
<string name="banner_set_up_recovery_submit">"Налаштувати відновлення"</string> <string name="banner_set_up_recovery_submit">"Отримати ключ відновлення"</string>
<string name="banner_set_up_recovery_title">"Налаштуйте відновлення для захисту свого облікового запису"</string> <string name="banner_set_up_recovery_title">"Створити резервну копію своїх бесід"</string>
<string name="confirm_recovery_key_banner_message">"Підтвердіть свій ключ відновлення, щоб мати доступ до сховища ключів та історії повідомлень."</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_primary_button_title">"Введіть ключ відновлення"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Забули ключ відновлення?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Забули ключ відновлення?"</string>
<string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string> <string name="confirm_recovery_key_banner_title">"Ваше сховище ключів не синхронізовано"</string>
<string name="full_screen_intent_banner_message">"Щоб ніколи не пропустити важливий виклик, змініть налаштування, щоб увімкнути повноекранні сповіщення, коли телефон заблоковано."</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_chats">"Бесіди"</string>
<string name="screen_home_tab_spaces">"Простори"</string> <string name="screen_home_tab_spaces">"Простори"</string>
<string name="screen_invites_decline_chat_message">"Ви впевнені, що хочете відхилити запрошення приєднатися до %1$s?"</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_mark_as_unread">"Позначити непрочитаним"</string>
<string name="screen_roomlist_tombstoned_room_description">"Цю кімнату оновлено"</string> <string name="screen_roomlist_tombstoned_room_description">"Цю кімнату оновлено"</string>
<string name="screen_roomlist_your_spaces">"Ваші простори"</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> <string name="session_verification_banner_title">"Підтвердьте, що це ви"</string>
</resources> </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.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState 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.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.dateformatter.api.DateFormatter 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.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService 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.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
@@ -230,7 +229,6 @@ class RoomListPresenterTest {
isDm = false, isDm = false,
isFavorite = false, isFavorite = false,
hasNewContent = false, hasNewContent = false,
displayClearRoomCacheAction = false,
) )
) )
} }
@@ -247,35 +245,6 @@ class RoomListPresenterTest {
isDm = false, isDm = false,
isFavorite = true, isFavorite = true,
hasNewContent = false, 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, isDm = false,
isFavorite = false, isFavorite = false,
hasNewContent = false, hasNewContent = false,
displayClearRoomCacheAction = false,
) )
) )
@@ -460,11 +428,17 @@ class RoomListPresenterTest {
val notificationCleaner = FakeNotificationCleaner( val notificationCleaner = FakeNotificationCleaner(
clearMessagesForRoomLambda = clearMessagesForRoomLambda, clearMessagesForRoomLambda = clearMessagesForRoomLambda,
) )
val markRoomAsRead = createTestMarkRoomAsRead(
client = matrixClient,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
)
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
analyticsService = analyticsService, analyticsService = analyticsService,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
markRoomAsRead = markRoomAsRead,
) )
presenter.test { presenter.test {
val initialState = awaitItem() 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( private fun TestScope.createRoomListPresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
leaveRoomState: LeaveRoomState = aLeaveRoomState(), leaveRoomState: LeaveRoomState = aLeaveRoomState(),
@@ -668,10 +660,10 @@ class RoomListPresenterTest {
spaceFiltersPresenter: Presenter<SpaceFiltersState> = Presenter { aDisabledSpaceFiltersState() }, spaceFiltersPresenter: Presenter<SpaceFiltersState> = Presenter { aDisabledSpaceFiltersState() },
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
@@ -688,15 +680,17 @@ class RoomListPresenterTest {
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
), ),
searchPresenter = searchPresenter, searchPresenter = searchPresenter,
sessionPreferencesStore = sessionPreferencesStore,
filtersPresenter = filtersPresenter, filtersPresenter = filtersPresenter,
spaceFiltersPresenter = spaceFiltersPresenter, spaceFiltersPresenter = spaceFiltersPresenter,
analyticsService = analyticsService, analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() }, batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner, markRoomAsRead = markRoomAsRead ?: createTestMarkRoomAsRead(
appPreferencesStore = appPreferencesStore, client = client,
notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore,
),
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(), coldStartWatcher = FakeAnalyticsColdStartWatcher(),
@@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_a_member">"Уже учасник"</string>
<string name="screen_invite_users_already_invited">"Уже запрошені"</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_multiple_users">"Наразі у вас немає бесід з цими контактами. Підтвердьте запрошення їх до цієї кімнати, перш ніж продовжувати."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Наразі у вас немає чатів із цим контактом. Підтвердьте запрошення до цієї кімнати, перш ніж продовжувати."</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_mutiple_users">"Запросити нових контактів до цієї кімнати?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Запросити нового контакта до цієї кімнати?"</string> <string name="screen_invite_users_confirm_dialog_title_one_user">"Запросити нового контакта до цієї кімнати?"</string>
</resources> </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_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_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_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_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_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> <string name="screen_join_room_knock_action">"Ketuk untuk bergabung"</string>
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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_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_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_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> </resources>
@@ -30,6 +30,7 @@ dependencies {
// TODO Cleanup // TODO Cleanup
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
implementation(projects.features.lockscreen.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
@@ -53,6 +54,7 @@ dependencies {
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.linknewdevice.test) testImplementation(projects.features.linknewdevice.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.enterprise.test) testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -13,8 +13,10 @@ import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext 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.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack 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.error.ErrorScreenType
import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode 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.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.root.LinkNewDeviceRootNode
import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode 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.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@@ -67,11 +71,16 @@ class LinkNewDeviceFlowNode(
private val linkNewMobileHandler: LinkNewMobileHandler, private val linkNewMobileHandler: LinkNewMobileHandler,
private val linkNewDesktopHandler: LinkNewDesktopHandler, private val linkNewDesktopHandler: LinkNewDesktopHandler,
private val sessionEnterpriseService: SessionEnterpriseService, private val sessionEnterpriseService: SessionEnterpriseService,
private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>( ) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
permanentNavModel = PermanentNavModel(
navTargets = setOf(NavTarget.LockScreen),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
) { ) {
@@ -124,6 +133,9 @@ class LinkNewDeviceFlowNode(
@Parcelize @Parcelize
data object DesktopScanQrCode : NavTarget data object DesktopScanQrCode : NavTarget
@Parcelize
data object LockScreen : NavTarget
@Parcelize @Parcelize
data class Error( data class Error(
val errorScreenType: ErrorScreenType, val errorScreenType: ErrorScreenType,
@@ -137,6 +149,9 @@ class LinkNewDeviceFlowNode(
Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}") Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
when (linkMobileStep) { when (linkMobileStep) {
LinkMobileStep.Uninitialized -> Unit LinkMobileStep.Uninitialized -> Unit
LinkMobileStep.CreatingQrCode -> {
// This step is handled in LinkNewDeviceRootPresenter
}
LinkMobileStep.Done -> { LinkMobileStep.Done -> {
callback.onDone() callback.onDone()
} }
@@ -224,13 +239,28 @@ class LinkNewDeviceFlowNode(
callback.onDone() callback.onDone()
} }
override fun linkDesktopDevice() { override fun onUnlockDevice(type: LinkDeviceType) {
linkNewDesktopHandler.reset() val callback = object : DeviceUnlockEntryPoint.Callback {
backstack.push(NavTarget.DesktopNotice) 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)) createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback))
} }
is NavTarget.LockScreen -> {
deviceUnlockEntryPoint.createNode(this, buildContext)
}
NavTarget.DesktopNotice -> { NavTarget.DesktopNotice -> {
val callback = object : DesktopNoticeNode.Callback { val callback = object : DesktopNoticeNode.Callback {
override fun navigateBack() { override fun navigateBack() {
@@ -324,5 +354,6 @@ class LinkNewDeviceFlowNode(
} }
} }
BackstackView() BackstackView()
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
} }
} }
@@ -43,12 +43,15 @@ class LinkNewMobileHandler(
val stepFlow: StateFlow<LinkMobileStep> val stepFlow: StateFlow<LinkMobileStep>
get() = linkMobileStepFlow.asStateFlow() get() = linkMobileStepFlow.asStateFlow()
fun createAndStartNewHandler() { fun createAndStartNewHandler(forRotating: Boolean = false) {
Timber.tag(loggerTag.value).d("createAndStartNewHandler()") Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
currentJob?.cancel() currentJob?.cancel()
handler = matrixClient.createLinkMobileHandler().getOrNull() handler = matrixClient.createLinkMobileHandler().getOrNull()
handler?.let { h -> handler?.let { h ->
currentJob = sessionScope.launch { currentJob = sessionScope.launch {
if (!forRotating) {
linkMobileStepFlow.emit(LinkMobileStep.CreatingQrCode)
}
h.linkMobileStep h.linkMobileStep
.onEach { .onEach {
linkMobileStepFlow.emit(it) linkMobileStepFlow.emit(it)
@@ -68,7 +71,7 @@ class LinkNewMobileHandler(
} }
fun rotateQrCode() { fun rotateQrCode() {
createAndStartNewHandler() createAndStartNewHandler(forRotating = true)
} }
fun onTooManyRotation() { fun onTooManyRotation() {
@@ -5,8 +5,9 @@
* Please see LICENSE files in the repository root for full details. * 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 { enum class LinkDeviceType {
fun provides(): List<ByteArray> Mobile,
Desktop,
} }
@@ -8,6 +8,5 @@
package io.element.android.features.linknewdevice.impl.screens.root package io.element.android.features.linknewdevice.impl.screens.root
sealed interface LinkNewDeviceRootEvent { sealed interface LinkNewDeviceRootEvent {
data object LinkMobileDevice : LinkNewDeviceRootEvent
data object CloseDialog : LinkNewDeviceRootEvent data object CloseDialog : LinkNewDeviceRootEvent
} }
@@ -27,7 +27,7 @@ class LinkNewDeviceRootNode(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onDone() fun onDone()
fun linkDesktopDevice() fun onUnlockDevice(type: LinkDeviceType)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -39,7 +39,7 @@ class LinkNewDeviceRootNode(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClick = callback::onDone, onBackClick = callback::onDone,
onLinkDesktopDeviceClick = callback::linkDesktopDevice, onUnlockDevice = callback::onUnlockDevice,
) )
} }
} }
@@ -50,7 +50,13 @@ class LinkNewDeviceRootPresenter(
LaunchedEffect(step) { LaunchedEffect(step) {
when (val finalStep = step) { when (val finalStep = step) {
is LinkMobileStep.Uninitialized -> { 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 -> { is LinkMobileStep.QrReady -> {
qrCodeData = AsyncData.Success(Unit) qrCodeData = AsyncData.Success(Unit)
@@ -64,12 +70,6 @@ class LinkNewDeviceRootPresenter(
fun handleEvent(event: LinkNewDeviceRootEvent) { fun handleEvent(event: LinkNewDeviceRootEvent) {
when (event) { when (event) {
LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
qrCodeData = AsyncData.Loading()
// Wait for the QrCode to be ready
linkNewMobileHandler.reset()
linkNewMobileHandler.createAndStartNewHandler()
}
LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch { LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
linkNewMobileHandler.reset() linkNewMobileHandler.reset()
} }
@@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun LinkNewDeviceRootView( fun LinkNewDeviceRootView(
state: LinkNewDeviceRootState, state: LinkNewDeviceRootState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onLinkDesktopDeviceClick: () -> Unit, onUnlockDevice: (type: LinkDeviceType) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) { val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
@@ -57,6 +57,7 @@ fun LinkNewDeviceRootView(
BigIcon.Style.Default(CompoundIcons.Devices()) BigIcon.Style.Default(CompoundIcons.Devices())
) )
} }
FlowStepPage( FlowStepPage(
onBackClick = onBackClick, onBackClick = onBackClick,
title = title, title = title,
@@ -83,40 +84,37 @@ fun LinkNewDeviceRootView(
} }
is AsyncData.Success -> { is AsyncData.Success -> {
if (state.isSupported.data) { if (state.isSupported.data) {
when (state.qrCodeData) { val canClick = state.qrCodeData is AsyncData.Uninitialized
AsyncData.Uninitialized, val isLoading = state.qrCodeData is AsyncData.Loading || state.qrCodeData is AsyncData.Success
is AsyncData.Failure -> { Button(
Button( onClick = {
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, if (canClick) {
text = stringResource(id = R.string.screen_link_new_device_root_mobile_device), onUnlockDevice(LinkDeviceType.Mobile)
modifier = Modifier.fillMaxWidth(), }
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), },
) text = stringResource(
Button( id = if (isLoading) {
onClick = onLinkDesktopDeviceClick, R.string.screen_link_new_device_root_loading_qr_code
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), } else {
modifier = Modifier.fillMaxWidth(), R.string.screen_link_new_device_root_mobile_device
leadingIcon = IconSource.Vector(CompoundIcons.Computer()), }
) ),
} showProgress = isLoading,
is AsyncData.Loading, enabled = !isLoading,
is AsyncData.Success -> { modifier = Modifier.fillMaxWidth(),
Button( leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, )
text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code), Button(
showProgress = true, onClick = {
enabled = false, if (canClick) {
modifier = Modifier.fillMaxWidth(), onUnlockDevice(LinkDeviceType.Desktop)
) }
Button( },
onClick = onLinkDesktopDeviceClick, text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), enabled = !isLoading,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = false, leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
leadingIcon = IconSource.Vector(CompoundIcons.Computer()), )
)
}
}
} else { } else {
Button( Button(
onClick = onBackClick, onClick = onBackClick,
@@ -147,6 +145,6 @@ internal fun LinkNewDeviceRootViewPreview(
LinkNewDeviceRootView( LinkNewDeviceRootView(
state = state, state = state,
onBackClick = { }, onBackClick = { },
onLinkDesktopDeviceClick = { }, onUnlockDevice = { },
) )
} }
@@ -13,6 +13,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint 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.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@@ -39,6 +40,7 @@ class DefaultLinkNewDeviceEntryPointTest {
linkNewMobileHandler = LinkNewMobileHandler(client), linkNewMobileHandler = LinkNewMobileHandler(client),
linkNewDesktopHandler = LinkNewDesktopHandler(client), linkNewDesktopHandler = LinkNewDesktopHandler(client),
sessionEnterpriseService = FakeSessionEnterpriseService(), sessionEnterpriseService = FakeSessionEnterpriseService(),
deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
) )
} }
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback { val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
@@ -88,6 +88,7 @@ class EnterNumberPresenterTest {
navigator = navigator, navigator = navigator,
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
).test { ).test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
linkMobileHandler.emitStep( linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender) LinkMobileStep.QrScanned(checkCodeSender)
@@ -96,7 +97,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1) skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue) initialState.eventSink(EnterNumberEvent.Continue)
skipItems(1) skipItems(2)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.sendingCode.isLoading()).isTrue() assertThat(finalState.sendingCode.isLoading()).isTrue()
advanceUntilIdle() advanceUntilIdle()
@@ -130,6 +131,7 @@ class EnterNumberPresenterTest {
LinkMobileStep.QrScanned(checkCodeSender) LinkMobileStep.QrScanned(checkCodeSender)
) )
runCurrent() runCurrent()
skipItems(1)
initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1) skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue) initialState.eventSink(EnterNumberEvent.Continue)
@@ -163,6 +165,7 @@ class EnterNumberPresenterTest {
createPresenter( createPresenter(
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
).test { ).test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
linkMobileHandler.emitStep( linkMobileHandler.emitStep(
LinkMobileStep.QrScanned(checkCodeSender) LinkMobileStep.QrScanned(checkCodeSender)
@@ -171,7 +174,7 @@ class EnterNumberPresenterTest {
initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
skipItems(1) skipItems(1)
initialState.eventSink(EnterNumberEvent.Continue) initialState.eventSink(EnterNumberEvent.Continue)
skipItems(1) skipItems(2)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.sendingCode.isLoading()).isTrue() assertThat(loadingState.sendingCode.isLoading()).isTrue()
expectNoEvents() expectNoEvents()
@@ -75,15 +75,48 @@ class LinkNewDeviceRootPresenterTest {
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
) )
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
createPresenter( createPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
linkNewMobileHandler = linkNewMobileHandler,
).test { ).test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isSupported.dataOrNull()).isTrue() assertThat(initialState.isSupported.dataOrNull()).isTrue()
initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) linkNewMobileHandler.createAndStartNewHandler()
skipItems(1)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.qrCodeData.isLoading()).isTrue() 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.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled 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.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce 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.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
@@ -42,29 +44,31 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
@Test @Test
fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest { fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false) val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true), isSupported = AsyncData.Success(true),
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
onLinkDesktopDeviceClick = callback, onUnlockDevice = callback,
) )
clickOn(R.string.screen_link_new_device_root_desktop_computer) clickOn(R.string.screen_link_new_device_root_desktop_computer)
} }
} }
@Test @Test
fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest { fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>() val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
setLinkNewDeviceRootView( ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
state = aLinkNewDeviceRootState( setLinkNewDeviceRootView(
isSupported = AsyncData.Success(true), state = aLinkNewDeviceRootState(
eventSink = eventRecorder, isSupported = AsyncData.Success(true),
eventSink = eventRecorder,
),
onUnlockDevice = callback,
) )
) clickOn(R.string.screen_link_new_device_root_mobile_device)
clickOn(R.string.screen_link_new_device_root_mobile_device) }
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
} }
@Test @Test
@@ -85,13 +89,13 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView( private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView(
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(), state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(), onUnlockDevice: (type: LinkDeviceType) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setContent {
LinkNewDeviceRootView( LinkNewDeviceRootView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,
onLinkDesktopDeviceClick = onLinkDesktopDeviceClick, onUnlockDevice = onUnlockDevice,
) )
} }
} }
@@ -9,6 +9,7 @@
package io.element.android.features.linknewdevice.impl.screens.scan package io.element.android.features.linknewdevice.impl.screens.scan
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode 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.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.robolectric.annotation.Config
class ScanQrCodeViewTest : RobolectricTest() { class ScanQrCodeViewTest : RobolectricTest() {
@Test @Test
@Config(sdk = [UPSIDE_DOWN_CAKE])
fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false) val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -42,6 +45,7 @@ class ScanQrCodeViewTest : RobolectricTest() {
} }
@Test @Test
@Config(sdk = [UPSIDE_DOWN_CAKE])
fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest { fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>() val eventRecorder = EventsRecorder<ScanQrCodeEvent>()
setView( setView(
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_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_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> </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) implementation(libs.androidx.biometric)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.test)
testImplementation(projects.libraries.cryptography.impl) 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 private var cryptoObject: CryptoObject? = null
override suspend fun setup() { override suspend fun setup() {
if (cryptoObject != null) return
try { try {
val secretKey = ensureKey() val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey) val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -109,7 +110,9 @@ private class AuthenticationCallback(
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result) 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() } callbacks.forEach { it.onBiometricAuthenticationSuccess() }
deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success) deferredAuthenticationResult.complete(BiometricAuthenticator.AuthenticationResult.Success)
} else { } else {
@@ -21,6 +21,11 @@ interface BiometricAuthenticatorManager {
*/ */
val hasAvailableAuthenticator: Boolean 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 addCallback(callback: BiometricAuthenticator.Callback)
fun removeCallback(callback: BiometricAuthenticator.Callback) fun removeCallback(callback: BiometricAuthenticator.Callback)
@@ -35,6 +40,12 @@ interface BiometricAuthenticatorManager {
@Composable @Composable
fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator 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. * 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.di.annotations.ApplicationContext
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
@@ -69,6 +70,12 @@ class DefaultBiometricAuthenticatorManager(
get() = lockScreenConfig.isStrongBiometricsEnabled && get() = lockScreenConfig.isStrongBiometricsEnabled &&
biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS 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. * Returns true if any biometric method (weak or strong) can be used.
*/ */
@@ -78,6 +85,9 @@ class DefaultBiometricAuthenticatorManager(
override val isDeviceSecured: Boolean override val isDeviceSecured: Boolean
get() = keyguardManager.isDeviceSecure get() = keyguardManager.isDeviceSecure
override val canUseDeviceUnlock: Boolean
get() = isDeviceSecured && (canUseWeakBiometricAuth || canUseStrongBiometricAuth || canUseDeviceCredentialAuth)
private val internalCallback = object : DefaultBiometricUnlockCallback() { private val internalCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricSetupError() { override fun onBiometricSetupError() {
coroutineScope.launch { disable() } coroutineScope.launch { disable() }
@@ -99,6 +109,28 @@ class DefaultBiometricAuthenticatorManager(
isAvailable = isAvailable, isAvailable = isAvailable,
promptTitle = promptTitle, promptTitle = promptTitle,
promptNegative = promptNegative, 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, isAvailable = isAvailable,
promptTitle = promptTitle, promptTitle = promptTitle,
promptNegative = promptNegative, promptNegative = promptNegative,
forDeviceUnlock = false,
) )
} }
@@ -126,7 +159,8 @@ class DefaultBiometricAuthenticatorManager(
private fun rememberBiometricAuthenticator( private fun rememberBiometricAuthenticator(
isAvailable: Boolean, isAvailable: Boolean,
promptTitle: String, promptTitle: String,
promptNegative: String, promptNegative: String?,
forDeviceUnlock: Boolean,
): BiometricAuthenticator { ): BiometricAuthenticator {
val activity = LocalContext.current.findFragmentActivity() val activity = LocalContext.current.findFragmentActivity()
return remember(isAvailable) { return remember(isAvailable) {
@@ -136,11 +170,21 @@ class DefaultBiometricAuthenticatorManager(
canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
else -> 0 else -> 0
} }
val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { val promptInfo = BiometricPrompt.PromptInfo.Builder()
setTitle(promptTitle) .setTitle(promptTitle)
setNegativeButtonText(promptNegative) .apply {
setAllowedAuthenticators(authenticators) if (promptNegative != null) {
}.build() setNegativeButtonText(promptNegative)
}
if (forDeviceUnlock) {
setAllowedAuthenticators(
authenticators or if (canUseDeviceCredentialAuth) BiometricManager.Authenticators.DEVICE_CREDENTIAL else 0
)
} else {
setAllowedAuthenticators(authenticators)
}
}
.build()
DefaultBiometricAuthentication( DefaultBiometricAuthentication(
activity = activity, activity = activity,
promptInfo = promptInfo, 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() { override fun onUnlock() {
backstack.newRoot(NavTarget.Settings) 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 -> { NavTarget.SetupPin -> {
createNode<SetupPinNode>(buildContext) createNode<SetupPinNode>(buildContext)
@@ -24,17 +24,23 @@ class PinUnlockHelper(
private val pinCodeManager: PinCodeManager private val pinCodeManager: PinCodeManager
) { ) {
@Composable @Composable
fun OnUnlockEffect(onUnlock: () -> Unit) { fun OnUnlockEffect(onUnlock: (Boolean) -> Unit) {
val latestOnUnlock by rememberUpdatedState(onUnlock) val latestOnUnlock by rememberUpdatedState(onUnlock)
DisposableEffect(Unit) { DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() { val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricAuthenticationSuccess() { override fun onBiometricAuthenticationSuccess() {
latestOnUnlock() latestOnUnlock(true)
}
override fun onBiometricAuthenticationFailed(error: Exception?) {
if (error != null) {
latestOnUnlock(false)
}
} }
} }
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() { val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() { override fun onPinCodeVerified() {
latestOnUnlock() latestOnUnlock(true)
} }
} }
biometricAuthenticatorManager.addCallback(biometricUnlockCallback) biometricAuthenticatorManager.addCallback(biometricUnlockCallback)
@@ -17,7 +17,9 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@@ -25,13 +27,23 @@ import io.element.android.libraries.di.SessionScope
class PinUnlockNode( class PinUnlockNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: PinUnlockPresenter, presenterFactory: PinUnlockPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onUnlock() fun onUnlock()
// For feature unlock
fun onCancel()
} }
data class Inputs(
val forDeviceUnlock: Boolean,
) : NodeInputs
private val callback: Callback = callback() private val callback: Callback = callback()
private val inputs: Inputs = inputs()
private val presenter: PinUnlockPresenter = presenterFactory.create(inputs.forDeviceUnlock)
@Composable @Composable
override fun View(modifier: Modifier) { 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. // UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true.
// It's set to false in PinUnlockActivity. // It's set to false in PinUnlockActivity.
isInAppUnlock = true, isInAppUnlock = true,
onCancel = callback::onCancel,
modifier = modifier modifier = modifier
) )
} }
@@ -16,7 +16,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue 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.BiometricAuthenticator
import io.element.android.features.lockscreen.impl.biometric.BiometricAuthenticatorManager 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.pin.PinCodeManager
@@ -32,8 +34,9 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @AssistedInject
class PinUnlockPresenter( class PinUnlockPresenter(
@Assisted private val forDeviceUnlock: Boolean,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricAuthenticatorManager: BiometricAuthenticatorManager, private val biometricAuthenticatorManager: BiometricAuthenticatorManager,
private val logoutUseCase: LogoutUseCase, private val logoutUseCase: LogoutUseCase,
@@ -41,6 +44,11 @@ class PinUnlockPresenter(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper, private val pinUnlockHelper: PinUnlockHelper,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@AssistedFactory
interface Factory {
fun create(forDeviceUnlock: Boolean): PinUnlockPresenter
}
@Composable @Composable
override fun present(): PinUnlockState { override fun present(): PinUnlockState {
val pinEntryState = remember { val pinEntryState = remember {
@@ -98,7 +106,7 @@ class PinUnlockPresenter(
} }
} }
pinUnlockHelper.OnUnlockEffect { pinUnlockHelper.OnUnlockEffect {
isUnlocked.value = true isUnlocked.value = it
} }
fun handleEvent(event: PinUnlockEvent) { fun handleEvent(event: PinUnlockEvent) {
@@ -128,6 +136,7 @@ class PinUnlockPresenter(
} }
} }
return PinUnlockState( return PinUnlockState(
canNavigateBack = forDeviceUnlock,
pinEntry = pinEntry, pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = remainingAttempts,
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
data class PinUnlockState( data class PinUnlockState(
val canNavigateBack: Boolean,
val pinEntry: AsyncData<PinEntry>, val pinEntry: AsyncData<PinEntry>,
val showWrongPinTitle: Boolean, val showWrongPinTitle: Boolean,
val remainingAttempts: AsyncData<Int>, val remainingAttempts: AsyncData<Int>,
@@ -38,10 +38,20 @@ open class PinUnlockStateProvider : PreviewParameterProvider<PinUnlockState> {
showWrongPinTitle = true, showWrongPinTitle = true,
isUnlocked = true, isUnlocked = true,
), ),
aPinUnlockState(canNavigateBack = true),
)
}
open class PinUnlockStateCompactProvider : PreviewParameterProvider<PinUnlockState> {
override val values: Sequence<PinUnlockState>
get() = sequenceOf(
aPinUnlockState(),
aPinUnlockState(canNavigateBack = true)
) )
} }
fun aPinUnlockState( fun aPinUnlockState(
canNavigateBack: Boolean = false,
pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)), pinEntry: AsyncData<PinEntry> = AsyncData.Success(PinEntry.createEmpty(4)),
remainingAttempts: AsyncData<Int> = AsyncData.Success(3), remainingAttempts: AsyncData<Int> = AsyncData.Success(3),
showWrongPinTitle: Boolean = false, showWrongPinTitle: Boolean = false,
@@ -51,6 +61,7 @@ fun aPinUnlockState(
isUnlocked: Boolean = false, isUnlocked: Boolean = false,
signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized, signOutAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = PinUnlockState( ) = PinUnlockState(
canNavigateBack = canNavigateBack,
pinEntry = pinEntry, pinEntry = pinEntry,
showWrongPinTitle = showWrongPinTitle, showWrongPinTitle = showWrongPinTitle,
remainingAttempts = remainingAttempts, remainingAttempts = remainingAttempts,
@@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle 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.architecture.AsyncData
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog 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.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -65,6 +67,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PinUnlockView( fun PinUnlockView(
state: PinUnlockState, state: PinUnlockState,
isInAppUnlock: Boolean, isInAppUnlock: Boolean,
onCancel: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
@@ -74,7 +77,7 @@ fun PinUnlockView(
} }
} }
Surface(modifier) { Surface(modifier) {
PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock) PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock, onCancel = onCancel)
if (state.showSignOutPrompt) { if (state.showSignOutPrompt) {
SignOutPrompt( SignOutPrompt(
isCancellable = state.isSignOutPromptCancellable, isCancellable = state.isSignOutPromptCancellable,
@@ -105,13 +108,14 @@ fun PinUnlockView(
private fun PinUnlockPage( private fun PinUnlockPage(
state: PinUnlockState, state: PinUnlockState,
isInAppUnlock: Boolean, isInAppUnlock: Boolean,
onCancel: () -> Unit,
) { ) {
BoxWithConstraints { BoxWithConstraints {
val commonModifier = Modifier val commonModifier = Modifier
.fillMaxSize() .fillMaxSize()
.systemBarsPadding() .systemBarsPadding()
.imePadding() .imePadding()
.padding(all = 20.dp) .padding(all = 20.dp)
val header = @Composable { val header = @Composable {
PinUnlockHeader( PinUnlockHeader(
@@ -147,8 +151,8 @@ private fun PinUnlockPage(
state.eventSink(PinUnlockEvent.OnPinEntryChanged(it)) state.eventSink(PinUnlockEvent.OnPinEntryChanged(it))
}, },
modifier = Modifier modifier = Modifier
.focusRequester(focusRequester) .focusRequester(focusRequester)
.fillMaxWidth() .fillMaxWidth()
) )
} }
} else { } else {
@@ -177,6 +181,15 @@ private fun PinUnlockPage(
modifier = commonModifier, 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( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight(), .fillMaxHeight(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
content() content()
@@ -239,9 +252,9 @@ private fun PinUnlockExpandedView(
header() header()
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.padding(top = 40.dp), .padding(top = 40.dp),
) { ) {
content() content()
} }
@@ -274,8 +287,8 @@ private fun PinDot(
} }
Box( Box(
modifier = Modifier modifier = Modifier
.size(14.dp) .size(14.dp)
.background(backgroundColor, CircleShape) .background(backgroundColor, CircleShape)
) )
} }
@@ -373,6 +386,7 @@ internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider:
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = true, isInAppUnlock = true,
onCancel = {},
) )
} }
} }
@@ -384,6 +398,19 @@ internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::clas
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = false, 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 lockScreenService: LockScreenService
@Inject lateinit var appPreferencesStore: AppPreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService @Inject lateinit var featureFlagService: FeatureFlagService
@@ -52,6 +52,7 @@ class PinUnlockActivity : AppCompatActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bindings<PinUnlockBindings>().inject(this) bindings<PinUnlockBindings>().inject(this)
val presenter = presenterFactory.create(forDeviceUnlock = false)
setContent { setContent {
val colors by remember { val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = null) enterpriseService.semanticColorsFlow(sessionId = null)
@@ -67,6 +68,9 @@ class PinUnlockActivity : AppCompatActivity() {
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = false, 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_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_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_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"> <plurals name="screen_app_lock_subtitle">
<item quantity="other">"Anda memiliki %1$d percobaan lagi untuk membuka kunci"</item> <item quantity="other">"Anda memiliki %1$d percobaan lagi untuk membuka kunci"</item>
</plurals> </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_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_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_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"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Você tem %1$d tentativa de desbloqueio"</item> <item quantity="one">"Você tem %1$d tentativa de desbloqueio"</item>
<item quantity="other">"Você tem %1$d tentativas 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> </plurals>
<string name="screen_app_lock_use_biometric_android">"Usar biometria"</string> <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_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> </resources>
@@ -15,15 +15,15 @@
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Заощаджуйте час і використовуйте %1$s для розблокування застосунку щоразу"</string> <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_choose_pin">"Виберіть PIN-код"</string>
<string name="screen_app_lock_setup_confirm_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_content">"Ви не можете вибрати його своїм PIN-кодом з міркувань безпеки"</string>
<string name="screen_app_lock_setup_pin_forbidden_dialog_title">"Виберіть інший 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_content">"Будь ласка, введіть один і той самий PIN-код двічі"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"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_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"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Ви маєте %1$d спробу"</item> <item quantity="one">"Ви маєте %1$d спробу"</item>
<item quantity="few">"Ви маєте %1$d спроби"</item> <item quantity="few">"Ви маєте %1$d спроби"</item>
@@ -8,10 +8,18 @@
package io.element.android.features.lockscreen.impl.biometric package io.element.android.features.lockscreen.impl.biometric
import io.element.android.tests.testutils.simulateLongTask
class FakeBiometricAuthenticator( class FakeBiometricAuthenticator(
override val isActive: Boolean = false, 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 { ) : BiometricAuthenticator {
override suspend fun setup() = Unit override suspend fun setup() = simulateLongTask {
override suspend fun authenticate() = authenticateLambda() setupLambda()
}
override suspend fun authenticate() = simulateLongTask {
authenticateLambda()
}
} }
@@ -12,8 +12,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
class FakeBiometricAuthenticatorManager( class FakeBiometricAuthenticatorManager(
override var isDeviceSecured: Boolean = true, override val isDeviceSecured: Boolean = true,
override var hasAvailableAuthenticator: Boolean = false, override val canUseDeviceUnlock: Boolean = true,
override val hasAvailableAuthenticator: Boolean = false,
private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() }, private val createBiometricAuthenticator: () -> BiometricAuthenticator = { FakeBiometricAuthenticator() },
private val disableLambda: suspend () -> Unit = { }, private val disableLambda: suspend () -> Unit = { },
) : BiometricAuthenticatorManager { ) : BiometricAuthenticatorManager {
@@ -28,7 +29,14 @@ class FakeBiometricAuthenticatorManager(
@Composable @Composable
override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator { override fun rememberUnlockBiometricAuthenticator(): BiometricAuthenticator {
return remember { 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) { private fun AsyncData<PinEntry>.assertText(text: String) {
dataOrNull()?.assertText(text) dataOrNull()?.assertText(text)
} }
private suspend fun TestScope.createPinUnlockPresenter( private suspend fun TestScope.createPinUnlockPresenter(
forDeviceUnlock: Boolean = false,
biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(), biometricAuthenticatorManager: BiometricAuthenticatorManager = FakeBiometricAuthenticatorManager(),
callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}), logoutUseCase: FakeLogoutUseCase = FakeLogoutUseCase(logoutLambda = {}),
@@ -162,6 +188,7 @@ class PinUnlockPresenterTest {
createPinCode(completePin) createPinCode(completePin)
} }
return PinUnlockPresenter( return PinUnlockPresenter(
forDeviceUnlock = forDeviceUnlock,
pinCodeManager = pinCodeManager, pinCodeManager = pinCodeManager,
biometricAuthenticatorManager = biometricAuthenticatorManager, biometricAuthenticatorManager = biometricAuthenticatorManager,
logoutUseCase = logoutUseCase, 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_step_5">"Επιστρέψτε στο %1$s"</string>
<string name="screen_missing_key_backup_title">"Ενεργοποιήστε την αποθήκευση κλειδιών σας πριν προχωρήσετε στο %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_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_manually">"Σύνδεση χειροκίνητα"</string>
<string name="screen_onboarding_sign_in_to">"Συνδέσου στο %1$s"</string> <string name="screen_onboarding_sign_in_to">"Συνδέσου στο %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Συνδέσου με κωδικό QR"</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_subtitle">"Qual é o endereço do seu servidor?"</string>
<string name="screen_change_server_title">"Selecione 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_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_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_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> <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_in_with_qr_code">"Увійти за допомогою QR-коду"</string>
<string name="screen_onboarding_sign_up">"Створити обліковий запис"</string> <string name="screen_onboarding_sign_up">"Створити обліковий запис"</string>
<string name="screen_onboarding_welcome_back">"З поверненням!"</string> <string name="screen_onboarding_welcome_back">"З поверненням!"</string>
<string name="screen_onboarding_welcome_message">"Ласкаво просимо до найшвидшого %1$s. Заряджений для швидкості та простоти."</string> <string name="screen_onboarding_welcome_message">"Вітаємо в %1$s. Сфокусованому на швидкості та простоті."</string>
<string name="screen_onboarding_welcome_subtitle">"Ласкаво просимо до %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_title">"Спілкуйтесь елементарно"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Встановлення безпечного з\'єднання"</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_description">"Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Що тепер?"</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_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_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_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_disabled_title">"Anda akan segera kehilangan akses ke obrolan terenkripsi Anda."</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_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_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_key_backup_ongoing_title">"Kunci Anda masih dicadangkan"</string>
<string name="screen_signout_preference_item">"Hapus device dari akun"</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_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_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> </resources>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_content">"Você tem certeza que deseja remover este dispositivo?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sair"</string> <string name="screen_signout_confirmation_dialog_submit">"Remover este dispositivo"</string>
<string name="screen_signout_confirmation_dialog_title">"Sair"</string> <string name="screen_signout_confirmation_dialog_title">"Remover este dispositivo"</string>
<string name="screen_signout_in_progress_dialog_content">"Saindo…"</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_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_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_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_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_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_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_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_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> <string name="screen_signout_save_recovery_key_title">"Você salvou sua chave de recuperação?"</string>
</resources> </resources>
@@ -1,18 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <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_content">"Ви впевнені, що хочете видалити цей пристрій?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Вийти"</string> <string name="screen_signout_confirmation_dialog_submit">"Вилучити цей пристрій"</string>
<string name="screen_signout_confirmation_dialog_title">"Вийти"</string> <string name="screen_signout_confirmation_dialog_title">"Вилучити цей пристрій"</string>
<string name="screen_signout_in_progress_dialog_content">"Видалення пристрою…"</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_subtitle">"Це ваш єдиний пристрій. Якщо ви його вилучите, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_key_backup_disabled_title">"Ви вимкнули резервне копіювання"</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_subtitle">"Резервні копії ваших ключів продовжували створюватися після вашого виходу з мережі. Під\'єднайтеся знову, щоб створити резервні копії ключів, перш ніж вилучати цей пристрій."</string>
<string name="screen_signout_key_backup_offline_title">"Резервне копіювання ваших ключів ще триває"</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_key_backup_ongoing_title">"Резервне копіювання ваших ключів ще триває"</string>
<string name="screen_signout_preference_item">"Вийти"</string> <string name="screen_signout_preference_item">"Вилучити цей пристрій"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Ви збираєтеся вийти зі свого останнього сеансу. Якщо ви вийдете зараз, ви втратите доступ до своїх зашифрованих повідомлень."</string> <string name="screen_signout_recovery_disabled_subtitle">"Це ваш єдиний пристрій. Якщо ви вилучите його, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_recovery_disabled_title">"Відновлення не налаштовано"</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_subtitle">"Це ваш єдиний пристрій. Якщо ви його вилучите, вам знадобиться ключ відновлення, щоб підтвердити свою цифрову ідентичність і відновити зашифровані бесіди під час наступного входу."</string>
<string name="screen_signout_save_recovery_key_title">"Ви зберегли ключ відновлення?"</string> <string name="screen_signout_save_recovery_key_title">"Переконайтеся, що у вас є доступ до ключа відновлення, перш ніж вилучити цей пристрій"</string>
</resources> </resources>
@@ -43,6 +43,7 @@ fun TimelineEventTimestampView(
event: TimelineItem.Event, event: TimelineItem.Event,
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isLayoutDirectionMismatched: Boolean = false,
) { ) {
val formattedTime = event.sentTime val formattedTime = event.sentTime
val hasError = event.failedToSend val hasError = event.failedToSend
@@ -77,9 +78,16 @@ fun TimelineEventTimestampView(
else -> Modifier else -> Modifier
} }
} }
val padding = if (!isLayoutDirectionMismatched) {
PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)
} else {
PaddingValues(end = TimelineEventTimestampViewDefaults.spacing)
}
Row( Row(
modifier = Modifier modifier = Modifier
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing)) .padding(padding)
// For a better click target, make the corners rounded // For a better click target, make the corners rounded
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.then(clickableModifier) .then(clickableModifier)
@@ -39,7 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.pluralStringResource 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.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex 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.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope 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.components.receipt.TimelineItemReadReceiptView
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.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.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent 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.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent 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.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent 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.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.modifiers.niceClickable
import io.element.android.libraries.designsystem.preview.ElementPreview 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.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState 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.ThreadSummary
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId 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.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.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl 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.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive 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 io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -649,23 +660,44 @@ private fun MessageEventBubbleContent(
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
) )
} }
TimestampPosition.Aligned -> TimestampPosition.Aligned -> @Composable {
ContentAvoidingLayout( val originalLayoutDirection = LocalLayoutDirection.current
modifier = modifier, // Detect if the direction of the text content (if any) does not match the layout direction, to place the content and timestamp correctly
// The spacing is negative to make the content overlap the empty space at the start of the timestamp val contentDirection = if (event.content is TimelineItemTextContent) {
spacing = (-4).dp, remember(event.content.body) {
overlayOffset = DpOffset(0.dp, -1.dp), when (TextDirection.detect(event.content.body)) {
shrinkContent = canShrinkContent, TextDirection.Ltr, TextDirection.ContentOrLtr -> LayoutDirection.Ltr
content = { content(this::onContentLayoutChange) }, TextDirection.Rtl, TextDirection.ContentOrRtl -> LayoutDirection.Rtl
overlay = { else -> originalLayoutDirection
TimelineEventTimestampView( }
event = event,
eventSink = eventSink,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
)
} }
) } 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 -> TimestampPosition.Below ->
Column(modifier) { Column(modifier) {
content {} 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 @PreviewsDayNight
@Composable @Composable
internal fun ThreadSummaryViewPreview() { internal fun ThreadSummaryViewPreview() {
@@ -15,13 +15,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp 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.libraries.designsystem.text.roundToPx
import io.element.android.wysiwyg.compose.EditorStyledText import io.element.android.wysiwyg.compose.EditorStyledText
import kotlin.math.max import kotlin.math.max
@@ -149,13 +149,13 @@ object ContentAvoidingLayout {
onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit,
extraWidth: Dp = 0.dp, extraWidth: Dp = 0.dp,
): ((TextLayoutResult) -> Unit) { ): ((TextLayoutResult) -> Unit) {
val layoutDirection = LocalLayoutDirection.current
val extraWidthPx = extraWidth.roundToPx() val extraWidthPx = extraWidth.roundToPx()
return { textLayout: TextLayoutResult -> return { textLayout: TextLayoutResult ->
// We need to add the external extra width so it's not taken into account as 'free space' // We need to add the external extra width so it's not taken into account as 'free space'
val lastLineWidth = when (layoutDirection) { val textDirection = tryOrNull { textLayout.getParagraphDirection(0) }
LayoutDirection.Ltr -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt() val lastLineWidth = when (textDirection) {
LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt() ResolvedTextDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
else -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
} }
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt() val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
onContentLayoutChange( onContentLayoutChange(
@@ -16,10 +16,18 @@
<string name="emoji_picker_category_places">"Voyages &amp; lieux"</string> <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_recent">"Emojis récents"</string>
<string name="emoji_picker_category_symbols">"Symboles"</string> <string name="emoji_picker_category_symbols">"Symboles"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner l’image 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 l’image 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 l’image vers la gauche"</string>
<plurals name="screen_image_edition_a11y_rotation_state"> <plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degré"</item> <item quantity="one">"%1$d degré"</item>
<item quantity="other">"%1$d degrés"</item> <item quantity="other">"%1$d degrés"</item>
</plurals> </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 d’anciennes applications."</string> <string name="screen_media_upload_preview_caption_warning">"Les légendes peuvent ne pas être visibles pour les utilisateurs d’anciennes applications."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Cliquez pour modifier la qualité d’envoi de la vidéo"</string> <string name="screen_media_upload_preview_change_video_quality_prompt">"Cliquez pour modifier la qualité d’envoi de la vidéo"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier n’a pas pu être envoyé."</string> <string name="screen_media_upload_preview_error_could_not_be_uploaded">"Le fichier n’a 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_item_count">"Élément %1$d sur %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiser la qualité de l’image"</string> <string name="screen_media_upload_preview_optimize_image_quality_title">"Optimiser la qualité de l’image"</string>
<string name="screen_media_upload_preview_processing">"Traitement en cours…"</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 l’utilisateur"</string> <string name="screen_report_content_block_user">"Bloquer l’utilisateur"</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_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é à l’administrateur de votre serveur d’accueil. Il ne pourra lire aucun message chiffré."</string> <string name="screen_report_content_explanation">"Ce message sera signalé à l’administrateur de votre serveur d’accueil. 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_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_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_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_optimize_image_quality_title">"Optimalkan kualitas gambar"</string>
<string name="screen_media_upload_preview_processing">"Memproses…"</string> <string name="screen_media_upload_preview_processing">"Memproses…"</string>
<string name="screen_report_content_block_user">"Blokir pengguna"</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_camera_video">"Gravar vídeo"</string>
<string name="screen_room_attachment_source_files">"Anexo"</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_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_source_poll">"Enquete"</string>
<string name="screen_room_attachment_text_formatting">"Formatação de texto"</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> <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"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_mismatched_sender">"Відправник події не збігається з власником пристрою, який його надіслав."</string> <string name="crypto_event_authenticity_mismatched_sender">"Відправник події не збігається з власником пристрою, який його надіслав."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Автентичність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої."</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_sent_in_clear">"Не зашифровано."</string>
<string name="crypto_event_authenticity_unknown_device">"Зашифровано невідомим або видаленим пристроєм."</string> <string name="crypto_event_authenticity_unknown_device">"Зашифровано невідомим або видаленим пристроєм."</string>
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не верифіковано його власником."</string> <string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не звірено його власником."</string>
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано неверифікованим користувачем."</string> <string name="crypto_event_authenticity_unverified_identity">"Зашифровано не звіреним користувачем."</string>
<string name="emoji_picker_category_activity">"Дії"</string> <string name="emoji_picker_category_activity">"Дії"</string>
<string name="emoji_picker_category_flags">"Прапори"</string> <string name="emoji_picker_category_flags">"Прапори"</string>
<string name="emoji_picker_category_foods">"Їжа та напої"</string> <string name="emoji_picker_category_foods">"Їжа та напої"</string>
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Подорожі та місця"</string> <string name="emoji_picker_category_places">"Подорожі та місця"</string>
<string name="emoji_picker_category_recent">"Нещодавні емодзі"</string> <string name="emoji_picker_category_recent">"Нещодавні емодзі"</string>
<string name="emoji_picker_category_symbols">"Символи"</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> <string name="screen_image_edition_a11y_rotate_to_the_left">"Повернути зображення вліво"</string>
<plurals name="screen_image_edition_a11y_rotation_state"> <plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d градус"</item> <item quantity="one">"%1$d градус"</item>
@@ -47,7 +53,7 @@
<string name="screen_room_attachment_source_poll">"Опитування"</string> <string name="screen_room_attachment_source_poll">"Опитування"</string>
<string name="screen_room_attachment_text_formatting">"Форматування тексту"</string> <string name="screen_room_attachment_text_formatting">"Форматування тексту"</string>
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</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_message">"Чи хотіли б ви запросити їх знову?"</string>
<string name="screen_room_invite_again_alert_title">"Ви одні в цій бесіді"</string> <string name="screen_room_invite_again_alert_title">"Ви одні в цій бесіді"</string>
<string name="screen_room_mentions_at_room_subtitle">"Сповістити всю кімнату"</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 class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents
data object ClearCache : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents
data object VacuumStores : 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.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase 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.ComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -46,6 +47,7 @@ class DeveloperSettingsPresenter(
private val vacuumStoresUseCase: VacuumStoresUseCase, private val vacuumStoresUseCase: VacuumStoresUseCase,
private val databaseSizesUseCase: GetDatabaseSizesUseCase, private val databaseSizesUseCase: GetDatabaseSizesUseCase,
private val fileSizeFormatter: FileSizeFormatter, private val fileSizeFormatter: FileSizeFormatter,
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@Composable @Composable
override fun present(): DeveloperSettingsState { override fun present(): DeveloperSettingsState {
@@ -58,6 +60,9 @@ class DeveloperSettingsPresenter(
val clearCacheAction = remember { val clearCacheAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
} }
val markAllRoomsAsReadAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
var showColorPicker by remember { var showColorPicker by remember {
mutableStateOf(false) mutableStateOf(false)
} }
@@ -88,6 +93,18 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch { DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch {
vacuumStoresUseCase() 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, cacheSize = cacheSize.value,
databaseSizes = databaseSizes.value, databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value, clearCacheAction = clearCacheAction.value,
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild, isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
showColorPicker = showColorPicker, showColorPicker = showColorPicker,
eventSink = ::handleEvent, eventSink = ::handleEvent,
@@ -131,8 +149,14 @@ class DeveloperSettingsPresenter(
} }
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch { private fun CoroutineScope.clearCache(clearCacheAction: MutableState<AsyncAction<Unit>>) = launch {
suspend { clearCacheUseCase() }.runCatchingUpdatingState(state = clearCacheAction)
}
private fun CoroutineScope.markAllRoomsAsRead(
markAllRoomsAsReadAction: MutableState<AsyncAction<Unit>>,
) = launch {
suspend { suspend {
clearCacheUseCase() markAllRoomsAsRead().getOrThrow()
}.runCatchingUpdatingState(clearCacheAction) }.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
} }
} }
@@ -18,9 +18,10 @@ data class DeveloperSettingsState(
val cacheSize: AsyncData<String>, val cacheSize: AsyncData<String>,
val databaseSizes: AsyncData<ImmutableMap<String, String>>, val databaseSizes: AsyncData<ImmutableMap<String, String>>,
val clearCacheAction: AsyncAction<Unit>, val clearCacheAction: AsyncAction<Unit>,
val markAllRoomsAsReadAction: AsyncAction<Unit>,
val isEnterpriseBuild: Boolean, val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean, val showColorPicker: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit 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( fun aDeveloperSettingsState(
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(), appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized, clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
isEnterpriseBuild: Boolean = false, isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false, showColorPicker: Boolean = false,
eventSink: (DeveloperSettingsEvents) -> Unit = {}, eventSink: (DeveloperSettingsEvents) -> Unit = {},
@@ -45,6 +46,7 @@ fun aDeveloperSettingsState(
cacheSize = AsyncData.Success("1.2 MB"), cacheSize = AsyncData.Success("1.2 MB"),
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")), databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction, clearCacheAction = clearCacheAction,
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
isEnterpriseBuild = isEnterpriseBuild, isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker, showColorPicker = showColorPicker,
eventSink = eventSink, eventSink = eventSink,
@@ -18,9 +18,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView 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.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.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -45,6 +47,15 @@ fun DeveloperSettingsView(
if (state.showLoader) { if (state.showLoader) {
ProgressDialog() 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( BackHandler(
enabled = !state.showLoader, enabled = !state.showLoader,
onBack = onBackClick, onBack = onBackClick,
@@ -64,6 +75,7 @@ fun DeveloperSettingsView(
onOpenShowkase = onOpenShowkase, onOpenShowkase = onOpenShowkase,
) )
NotificationCategory(onPushHistoryClick) NotificationCategory(onPushHistoryClick)
MarkAllRoomsAsReadCategory(state)
if (state.isEnterpriseBuild) { if (state.isEnterpriseBuild) {
PreferenceCategory(title = "Theme") { 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 @Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) { private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { 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_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_set_sound_error_title">"Viga helina seadistamisel"</string>
<string name="screen_notification_settings_sound_silent">"Vaikne"</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_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">"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_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> <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