Merge branch 'develop' into fix/thread-list-avatar

This commit is contained in:
ganfra
2026-06-26 11:07:31 +02:00
committed by GitHub
994 changed files with 4327 additions and 4099 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
+1 -1
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
+2 -2
View File
@@ -18,12 +18,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
+24 -24
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,7 +333,7 @@ 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@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
@@ -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
+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 {
@@ -24,14 +24,12 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.x.MainActivity import io.element.android.x.MainActivity
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class) class DefaultIntentProviderTest : RobolectricTest() {
class DefaultIntentProviderTest {
@Test @Test
fun `test getViewRoomIntent with data`() { fun `test getViewRoomIntent with data`() {
val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, EventId?, String> { _, _, _, _ -> "deepLinkCreatorResult" } val deepLinkCreator = lambdaRecorder<SessionId, RoomId?, ThreadId?, EventId?, String> { _, _, _, _ -> "deepLinkCreatorResult" }
@@ -45,15 +45,13 @@ import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMess
import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) class JoinedRoomLoadedFlowNodeTest : RobolectricTest() {
class JoinedRoomLoadedFlowNodeTest {
@get:Rule @get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule() val instantTaskExecutorRule = InstantTaskExecutorRule()
@@ -29,13 +29,11 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthAction
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class) class IntentResolverTest : RobolectricTest() {
class IntentResolverTest {
@Test @Test
fun `resolve launcher intent should return null`() { fun `resolve launcher intent should return null`() {
val sut = createIntentResolver() val sut = createIntentResolver()
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -14,7 +14,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent import io.element.android.features.announcement.impl.AnnouncementEvent
import io.element.android.features.announcement.impl.AnnouncementState import io.element.android.features.announcement.impl.AnnouncementState
@@ -23,11 +22,10 @@ import io.element.android.libraries.ui.strings.CommonStrings
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.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class FullscreenAnnouncementViewTest : RobolectricTest() {
class FullscreenAnnouncementViewTest {
@Test @Test
fun `clicking on back sends a AnnouncementEvent`() = runAndroidComposeUiTest { fun `clicking on back sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AnnouncementEvent>() val eventsRecorder = EventsRecorder<AnnouncementEvent>()
@@ -207,7 +207,6 @@ class WebViewAudioManager(
} }
audioManager.mode = AudioManager.MODE_NORMAL audioManager.mode = AudioManager.MODE_NORMAL
if (!hasRegisteredCallbacks) { if (!hasRegisteredCallbacks) {
Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks") Timber.w("Audio: tried to disable webview in-call audio mode without registering callbacks")
return return
@@ -342,6 +341,7 @@ class WebViewAudioManager(
} else { } else {
// On Android 11 and lower, we don't have the concept of communication devices // On Android 11 and lower, we don't have the concept of communication devices
// We have to call the right methods based on the device type // We have to call the right methods based on the device type
@Suppress("DEPRECATION")
if (device != null) { if (device != null) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) { if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) {
Timber.w("Bluetooth audio devices are disabled on this Android version") Timber.w("Bluetooth audio devices are disabled on this Android version")
@@ -350,11 +350,9 @@ class WebViewAudioManager(
return return
} }
setAudioEnabled(true) setAudioEnabled(true)
@Suppress("DEPRECATION")
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else { } else {
@Suppress("DEPRECATION")
isSpeakerphoneOn = false isSpeakerphoneOn = false
isBluetoothScoOn = false isBluetoothScoOn = false
} }
@@ -21,19 +21,17 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class) class DefaultElementCallEntryPointTest : RobolectricTest() {
class DefaultElementCallEntryPointTest {
@Test @Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint() val entryPoint = createEntryPoint()
@@ -23,13 +23,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) class RingingCallNotificationCreatorTest : RobolectricTest() {
class RingingCallNotificationCreatorTest {
@Test @Test
fun `createNotification - with no associated MatrixClient does nothing`() = runTest { fun `createNotification - with no associated MatrixClient does nothing`() = runTest {
val notificationCreator = createRingingCallNotificationCreator( val notificationCreator = createRingingCallNotificationCreator(
@@ -15,7 +15,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.call.impl.pip.PictureInPictureEvent import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState
@@ -26,9 +25,9 @@ import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge
import io.element.android.features.call.impl.ui.aCallScreenState import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements import org.robolectric.annotation.Implements
@@ -36,8 +35,7 @@ import org.robolectric.annotation.Resetter
import org.robolectric.shadows.ShadowWebView import org.robolectric.shadows.ShadowWebView
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
@RunWith(AndroidJUnit4::class) class CallScreenViewTest : RobolectricTest() {
class CallScreenViewTest {
@Test @Test
fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest { fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
val callEvents = EventsRecorder<CallScreenEvent>() val callEvents = EventsRecorder<CallScreenEvent>()
@@ -45,6 +45,7 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.plantTestTimber import io.element.android.tests.testutils.plantTestTimber
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@@ -54,12 +55,9 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class) class DefaultActiveCallManagerTest : RobolectricTest() {
class DefaultActiveCallManagerTest {
private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL) private val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.INCOMING_CALL)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -56,6 +56,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.mockk.mockk import io.mockk.mockk
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@@ -66,8 +67,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File import java.io.File
import java.util.Optional import java.util.Optional
@@ -75,8 +74,7 @@ private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2" private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class) class ConfigureRoomPresenterTest : RobolectricTest() {
class ConfigureRoomPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.deactivation.impl.R import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -29,12 +28,11 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) class AccountDeactivationViewTest : RobolectricTest() {
class AccountDeactivationViewTest {
@Test @Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false)
@@ -14,7 +14,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
@@ -24,11 +23,10 @@ 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.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class ForwardMessagesViewTest : RobolectricTest() {
class ForwardMessagesViewTest {
@Test @Test
fun `cancel error emits the expected event`() = runAndroidComposeUiTest { fun `cancel error emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>() val eventsRecorder = EventsRecorder<ForwardMessagesEvents>()
@@ -14,19 +14,17 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.ftue.impl.R import io.element.android.features.ftue.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.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.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) class ChooseSessionVerificationModeViewTest : RobolectricTest() {
class ChooseSessionVerificationModeViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on learn more invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on learn more invokes the expected callback`() = runAndroidComposeUiTest {
@@ -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
@@ -54,7 +53,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.timeline.ReceiptType 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.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.libraries.push.api.notifications.NotificationCleaner
@@ -87,7 +85,6 @@ class RoomListPresenter(
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 notificationCleaner: NotificationCleaner,
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 +154,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 +276,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
@@ -336,10 +331,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,
) )
@@ -8,7 +8,6 @@
package io.element.android.features.home.impl package io.element.android.features.home.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.home.api.HomeEntryPoint
@@ -18,12 +17,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
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
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class DefaultHomeEntryPointTest : RobolectricTest() {
class DefaultHomeEntryPointTest {
@Test @Test
fun `test node builder`() = runTest { fun `test node builder`() = runTest {
val entryPoint = DefaultHomeEntryPoint() val entryPoint = DefaultHomeEntryPoint()
@@ -13,18 +13,16 @@ package io.element.android.features.home.impl.filters
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
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.pressTag import io.element.android.tests.testutils.pressTag
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class RoomListFiltersViewTest : RobolectricTest() {
class RoomListFiltersViewTest {
@Test @Test
fun `clicking on filters generates expected Event`() = runAndroidComposeUiTest { fun `clicking on filters generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListFiltersEvent>() val eventsRecorder = EventsRecorder<RoomListFiltersEvent>()
@@ -14,7 +14,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -22,12 +21,11 @@ import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam 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.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class RoomListContextMenuTest : RobolectricTest() {
class RoomListContextMenuTest {
@Test @Test
fun `clicking on Mark as read generates expected Events`() = runAndroidComposeUiTest { fun `clicking on Mark as read generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
@@ -12,19 +12,17 @@ package io.element.android.features.home.impl.roomlist
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.model.aRoomListRoomSummary import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam 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.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class RoomListDeclineInviteMenuTest : RobolectricTest() {
class RoomListDeclineInviteMenuTest {
@Test @Test
fun `clicking on decline emits the expected Events`() = runAndroidComposeUiTest { fun `clicking on decline emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
@@ -63,9 +63,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.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 +228,6 @@ class RoomListPresenterTest {
isDm = false, isDm = false,
isFavorite = false, isFavorite = false,
hasNewContent = false, hasNewContent = false,
displayClearRoomCacheAction = false,
) )
) )
} }
@@ -247,35 +244,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 +271,6 @@ class RoomListPresenterTest {
isDm = false, isDm = false,
isFavorite = false, isFavorite = false,
hasNewContent = false, hasNewContent = false,
displayClearRoomCacheAction = false,
) )
) )
@@ -668,7 +635,6 @@ 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(),
@@ -696,7 +662,6 @@ class RoomListPresenterTest {
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() }, batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
appPreferencesStore = appPreferencesStore,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(), coldStartWatcher = FakeAnalyticsColdStartWatcher(),
@@ -19,7 +19,6 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.HomeView import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.aHomeState import io.element.android.features.home.impl.aHomeState
@@ -34,13 +33,12 @@ 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.ensureCalledOnceWithParam
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) class RoomListViewTest : RobolectricTest() {
class RoomListViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() = runAndroidComposeUiTest { fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() = runAndroidComposeUiTest {
@@ -15,14 +15,12 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class SpaceFiltersViewTest : RobolectricTest() {
class SpaceFiltersViewTest {
@Test @Test
fun `clicking on a filter with alias shows display name and alias`() = runAndroidComposeUiTest { fun `clicking on a filter with alias shows display name and alias`() = runAndroidComposeUiTest {
val filter = aSpaceServiceFilter( val filter = aSpaceServiceFilter(
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invite.impl.R import io.element.android.features.invite.impl.R
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
@@ -24,11 +23,10 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class DeclineAndBlockViewTest : RobolectricTest() {
class DeclineAndBlockViewTest {
@Test @Test
fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false)
@@ -14,7 +14,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.test.anInviteData import io.element.android.features.invite.test.anInviteData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -29,11 +28,10 @@ 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.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class JoinRoomViewTest : RobolectricTest() {
class JoinRoomViewTest {
@Test @Test
fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -24,11 +23,10 @@ import io.element.android.tests.testutils.EnsureNeverCalled
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.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class KnockRequestsBannerViewTest : RobolectricTest() {
class KnockRequestsBannerViewTest {
@Test @Test
fun `clicking on view on single request invoke the expected callback`() = runAndroidComposeUiTest { fun `clicking on view on single request invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
@@ -14,7 +14,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -25,12 +24,11 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class KnockRequestsListViewTest : RobolectricTest() {
class KnockRequestsListViewTest {
@Test @Test
fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false)
@@ -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 {
@@ -13,7 +13,6 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.linknewdevice.impl.R import io.element.android.features.linknewdevice.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@@ -21,11 +20,10 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class DesktopNoticeViewTest : RobolectricTest() {
class DesktopNoticeViewTest {
@Test @Test
fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -13,17 +13,15 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
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.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class ErrorViewTest : RobolectricTest() {
class ErrorViewTest {
@Test @Test
fun `on back pressed - calls the onCancel callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the onCancel callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { 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()
@@ -15,7 +15,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@@ -23,11 +22,10 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class EnterNumberViewTest : RobolectricTest() {
class EnterNumberViewTest {
@Test @Test
fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -13,15 +13,13 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.ensureCalledOnce 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 org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class ShowQrCodeViewTest : RobolectricTest() {
class ShowQrCodeViewTest {
@Test @Test
fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
@@ -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()
} }
} }
@@ -13,20 +13,20 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.linknewdevice.impl.R import io.element.android.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 org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class LinkNewDeviceRootViewTest : RobolectricTest() {
class LinkNewDeviceRootViewTest {
@Test @Test
fun `on back pressed - calls the onRetry callback`() = runAndroidComposeUiTest { fun `on back pressed - calls the onRetry callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false) val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
@@ -44,29 +44,31 @@ class LinkNewDeviceRootViewTest {
@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
@@ -87,13 +89,13 @@ class LinkNewDeviceRootViewTest {
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,
) )
} }
} }
@@ -15,7 +15,6 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -24,11 +23,10 @@ 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.pressBackKey import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class ScanQrCodeViewTest : RobolectricTest() {
class ScanQrCodeViewTest {
@Test @Test
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)
@@ -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>
@@ -17,7 +17,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -26,11 +25,10 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class ShareLocationViewTest : RobolectricTest() {
class ShareLocationViewTest {
@Test @Test
fun `test back action`() = runAndroidComposeUiTest { fun `test back action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false)
@@ -19,7 +19,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@@ -29,11 +28,10 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class ShowLocationViewTest : RobolectricTest() {
class ShowLocationViewTest {
@Test @Test
fun `test back action`() = runAndroidComposeUiTest { fun `test back action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false)
@@ -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
},
) )
} }
} }
@@ -8,15 +8,13 @@
package io.element.android.features.lockscreen.impl package io.element.android.features.lockscreen.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class DefaultLockScreenEntryPointIntentTest : RobolectricTest() {
class DefaultLockScreenEntryPointIntentTest {
@Test @Test
fun `test pin unlock intent`() { fun `test pin unlock intent`() {
val entryPoint = DefaultLockScreenEntryPoint() val entryPoint = DefaultLockScreenEntryPoint()
@@ -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,
@@ -28,12 +28,10 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam 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.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) class PinKeypadTest : RobolectricTest() {
class PinKeypadTest {
@Test @Test
fun `clicking on a number emits the expected event`() = runAndroidComposeUiTest { fun `clicking on a number emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinKeypadModel>() val eventsRecorder = EventsRecorder<PinKeypadModel>()
@@ -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()
}
@@ -8,14 +8,12 @@
package io.element.android.features.login.impl package io.element.android.features.login.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.LoginParams
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class DefaultLoginIntentResolverTest : RobolectricTest() {
class DefaultLoginIntentResolverTest {
@Test @Test
fun `nominal case`() { fun `nominal case`() {
val sut = DefaultLoginIntentResolver() val sut = DefaultLoginIntentResolver()
@@ -28,17 +28,15 @@ import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatib
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) class DefaultElementClassicConnectionTest : RobolectricTest() {
class DefaultElementClassicConnectionTest {
@Test @Test
fun `connection can be started Element Classic service can be bound`() = runTest { fun `connection can be started Element Classic service can be bound`() = runTest {
val connection = createDefaultElementClassicConnection( val connection = createDefaultElementClassicConnection(
@@ -8,7 +8,6 @@
package io.element.android.features.login.impl.qrcode package io.element.android.features.login.impl.qrcode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.AncestryInfo import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
@@ -22,16 +21,15 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) class QrCodeLoginFlowNodeTest : RobolectricTest() {
class QrCodeLoginFlowNodeTest {
@Test @Test
fun `backstack changes when confirmation steps are received`() = runTest { fun `backstack changes when confirmation steps are received`() = runTest {
val qrCodeLoginManager = FakeQrCodeLoginManager() val qrCodeLoginManager = FakeQrCodeLoginManager()
@@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.accountprovider.anAccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.matrix.api.auth.OAuthDetails
@@ -28,12 +27,11 @@ 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.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) class ChooseAccountProviderViewTest : RobolectricTest() {
class ChooseAccountProviderViewTest {
@Test @Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>(expectEvents = false) val eventSink = EventsRecorder<ChooseAccountProviderEvents>(expectEvents = false)
@@ -10,7 +10,6 @@
package io.element.android.features.login.impl.screens.classic package io.element.android.features.login.impl.screens.classic
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.classic.ElementClassicConnection import io.element.android.features.login.impl.classic.ElementClassicConnection
@@ -26,16 +25,15 @@ import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
// Use AndroidJUnit4 for the test with the Bitmap. // Use RobolectricTest for the test with the Bitmap.
@RunWith(AndroidJUnit4::class) class ClassicFlowNodeHelperTest : RobolectricTest() {
class ClassicFlowNodeHelperTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()

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