Merge branch 'develop' into fix/2614-linkify-ipv6-urls

This commit is contained in:
Benoit Marty
2026-08-12 13:48:36 +02:00
committed by GitHub
739 changed files with 6362 additions and 3368 deletions
+2 -2
View File
@@ -51,12 +51,12 @@ jobs:
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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
+2 -2
View File
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug Gplay Enterprise APK - name: Assemble debug Gplay Enterprise APK
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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
+2 -2
View File
@@ -44,13 +44,13 @@ jobs:
# 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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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'
+4 -4
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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: false cache-read-only: false
@@ -80,12 +80,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
+13 -13
View File
@@ -69,12 +69,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -336,7 +336,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
upload_reports: upload_reports:
name: Project Check Suite name: Project Check Suite
+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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
+6 -6
View File
@@ -38,12 +38,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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 }}
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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 }}
@@ -126,12 +126,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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 }}
+2 -2
View File
@@ -45,12 +45,12 @@ jobs:
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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Use JDK 21 - name: Use JDK 21
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.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
@@ -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@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.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@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
+92
View File
@@ -1,3 +1,95 @@
Changes in Element X v26.08.1
=============================
<!-- Release notes generated using configuration in .github/release.yml at v26.08.1 -->
## What's Changed
### 🙌 Improvements
* Render push notifications for live location share starts (MSC4505) by @ara4n in https://github.com/element-hq/element-x-android/pull/7203
* [Task] Show active call participants in the people list #7199 by @Eklavya47 in https://github.com/element-hq/element-x-android/pull/7300
### 🐛 Bugfixes
* Fix overlap issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/7331
* Announce checked/unchecked state for user rows in invite screen by @bmarty in https://github.com/element-hq/element-x-android/pull/7136
* Display 'add reactions' menu in the threads screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7388
* Expand the emoji picker to full height when the keyboard is shown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7382
* UI fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/7409
### 🗣 Translations
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7389
### 🧱 Build
* Don't check enterprise previews in `KonsistPreviewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7297
* CI: split unit tests, screenshots and coverage in `tests.yml` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7296
* Update Gradle Wrapper from 9.6.1 to 9.7.0 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7335
### 🚧 In development 🚧
* QR code login iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/6719
* User status last pieces by @ganfra in https://github.com/element-hq/element-x-android/pull/7328
* [Link new device] Add timeout by @bmarty in https://github.com/element-hq/element-x-android/pull/7338
### Dependency upgrades
* Update dependency org.maplibre.compose:maplibre-compose to v0.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7291
* Update android.gradle.plugin to v9.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7248
* Update dependency io.sentry:sentry-android to v8.50.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7298
* Update zizmorcore/zizmor-action action to v0.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7306
* Update android.gradle.plugin to v9.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7304
* Update dependency com.posthog:posthog-android to v3.56.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7311
* Update dependencyAnalysis to v3.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7318
* Update actions/stale action to v11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7317
* Update dependency io.github.zxing-cpp:android to v3.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7322
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.05 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7324
* Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7329
* Update reactivecircus/android-emulator-runner action to v2.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7189
* Update dependency io.sentry:sentry-android to v8.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7301
* Update dependency org.maplibre.gl:android-sdk to v13.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7305
* Update dependency io.element.android:element-call-embedded to v0.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7323
* Update metro to v1.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7334
* Update dependency com.google.firebase:firebase-bom to v34.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7333
* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7175
* Update dependency org.jsoup:jsoup to v1.23.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7332
* Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7330
* Update zizmorcore/zizmor-action action to v0.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7383
* Update actions/setup-java action to v5.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7339
* Update gradle/actions action to v6.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7384
* Update plugin dependencycheck to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7386
* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7402
### 💼 Element Pro
* Add support for checking the ESS config in Element Pro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7302
### Others
* Add extra vertical padding to `ReplyToInvalidContent` and `ReplyToNotFoundContent` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7288
* Index messages locally in an encrypted on-device store so encrypted rooms can be searched at all by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7249
* Bump enterprise module to the latest commit by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7307
* Bump enterprise module to the latest commit by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7309
* Move deviceId and git details to developer settings by @bmarty in https://github.com/element-hq/element-x-android/pull/7241
* Show a Message not found placeholder when a replied to message cannot be loaded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7376
* Update in reply to message when message is not found. by @bmarty in https://github.com/element-hq/element-x-android/pull/7387
* Add default `OAUTH_CLIENT_URL_PATH` alternative by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7385
* Fix the feature module template writing the impl build script into the api module by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7374
* Let users enter their full Matrix user ID when searching for a homeserver by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7375
* Keep manually revealed media visible when re-entering a room by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7378
* Copy a blocked user's Matrix ID by long pressing them by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7372
* Do not draw the media loading progress bar under the status bar by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7366
* Do not notify about an event sent by another account on the same device by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7360
* Keep the opened image on screen when the media gallery fails to load by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7362
* Do not linkify the server part of a fediverse handle as an email address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7356
* Expose global account data get and set on MatrixClient by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7373
* Show the alias or the direct message user's Matrix ID in the room picker by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7371
* Use a dedicated string for the Mute room notification mode by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7367
* Keep playing a voice message when the screen turns off by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7365
* Let a voice message without a waveform be seeked by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7363
* Ignore a second voice recording start while one is already running by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7346
* Do not offer to start a call in a DM with no other member by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7352
* Do not notify about a redaction whose target event is unknown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7350
* Ignore a room list filter that is incompatible with an already selected one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7342
* Stop a right to left sender name from reordering a room list preview by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7348
* Do not append the application MapTiler key to a custom map style url by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7340
* Hide space children you have left that can only be joined by invitation by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7369
* Never re-encode a video at a higher bitrate than the source by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7351
* Display push notifications for knocks by @ara4n in https://github.com/element-hq/element-x-android/pull/7207
## New Contributors
* @hayaksi1 made their first contribution in https://github.com/element-hq/element-x-android/pull/7249
* @ara4n made their first contribution in https://github.com/element-hq/element-x-android/pull/7203
* @Eklavya47 made their first contribution in https://github.com/element-hq/element-x-android/pull/7300
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.0...v26.08.1
Changes in Element X v26.08.0 Changes in Element X v26.08.0
============================= =============================
+1
View File
@@ -60,6 +60,7 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.features.login.test) testImplementation(projects.features.login.test)
testImplementation(projects.features.share.test) testImplementation(projects.features.share.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.oauth.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
@@ -30,6 +30,8 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.RecoveryState
@@ -65,6 +67,7 @@ class LoggedInPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor, private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
private val featureFlagService: FeatureFlagService,
permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
private val localNetworkPermissionsPresenter: PermissionsPresenter = private val localNetworkPermissionsPresenter: PermissionsPresenter =
@@ -120,6 +123,16 @@ class LoggedInPresenter(
}.launchIn(this) }.launchIn(this)
} }
LaunchedEffect(Unit) {
// Keep automatic call status (m.call) in sync with the feature flag and homeserver support.
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.onEach { isUserStatusEnabled ->
val enabled = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
matrixClient.enableAutomaticCallStatus(enabled)
}
.launchIn(this)
}
val networkConnectivity by networkMonitor.connectivity.collectAsState() val networkConnectivity by networkMonitor.connectivity.collectAsState()
LaunchedEffect(networkConnectivity) { LaunchedEffect(networkConnectivity) {
if (networkConnectivity == NetworkStatus.Connected) { if (networkConnectivity == NetworkStatus.Connected) {
@@ -17,6 +17,9 @@ import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -48,6 +51,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.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
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
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = FakeFeatureFlagService(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
).test { ).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN) encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
@@ -346,6 +351,57 @@ class LoggedInPresenterTest {
} }
} }
@Test
fun `present - does not enable automatic call status when server does not support it`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(false)
}
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to true),
),
).test {
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledOnce()
.with(value(false))
}
}
@Test
fun `present - reacts to user status flag being toggled at runtime`() = runTest {
val enableAutomaticCallStatusLambda = lambdaRecorder<Boolean, Unit> { }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
enableAutomaticCallStatusLambda = enableAutomaticCallStatusLambda,
).apply {
isUserStatusSupportedResult = Result.success(true)
}
val featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.UserStatus.key to false),
)
createLoggedInPresenter(
matrixClient = matrixClient,
featureFlagService = featureFlagService,
).test {
awaitItem()
// Turning the flag on at runtime enables it.
featureFlagService.setFeatureEnabled(FeatureFlags.UserStatus, true)
cancelAndConsumeRemainingEvents()
assert(enableAutomaticCallStatusLambda)
.isCalledExactly(2)
.withSequence(
listOf(value(false)),
listOf(value(true)),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T { private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1) skipItems(1)
return awaitItem() return awaitItem()
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
), ),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
buildMeta = buildMeta, buildMeta = buildMeta,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(), localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
featureFlagService = featureFlagService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(), permissionsPresenterFactory = FakePermissionsPresenterFactory(),
) )
} }
+6 -2
View File
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
- Click on OK - Click on OK
4. Configure generate-module-from-template plugin : 4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings - Navigate to AS/Settings/Tools/Module Template Settings
- If a `FeatureModule` template is already listed, select it and remove it, since the plugin keeps its own copy of the
imported template and will not pick up changes made to `FeatureModule.json` on its own
- Click on + / Import From File - Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json` - Pick the `tools/templates/FeatureModule.json`
@@ -321,8 +323,10 @@ Example for a new feature called RoomDetails:
5. The modules api/impl should be created under `features/roomdetails` directory. 5. The modules api/impl should be created under `features/roomdetails` directory.
6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle). 6. Sync project with Gradle so the modules are recognized (no need to add them to settings.gradle).
7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`. 7. You can now add more Presentation classes (Events, State, StateProvider, View, Presenter) in the impl module with the `Template Presentation Classes`.
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`. To use it, just right click on the package where you want to generate classes, and click on `New` / `Template Presentation Classes`.
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package. Fill the text field with the base name of the classes: the generated files are `<BaseName>Event.kt`, `<BaseName>State.kt`,
`<BaseName>StateProvider.kt`, `<BaseName>Presenter.kt`, `<BaseName>Node.kt` and `<BaseName>View.kt`. For instance
`PreferencesRoot` in the `features/preferences/impl/root` package.
Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a Note that naming of files and classes is important, since those names are used to set up code coverage rules. For instance, presenters MUST have a
@@ -0,0 +1,2 @@
Main changes in this version: bug fixes and improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string> <string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string> <string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string> <string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string> <string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string> <string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string> <string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string> <string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string> <string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string> <string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string> <string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
<string name="screen_deactivate_account_title">"停用帳號"</string> <string name="screen_deactivate_account_title">"刪除帳號"</string>
</resources> </resources>
@@ -10,6 +10,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -17,7 +18,7 @@ import kotlinx.coroutines.flow.Flow
interface EnterpriseService { interface EnterpriseService {
val isEnterpriseBuild: Boolean val isEnterpriseBuild: Boolean
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
fun defaultHomeserverList(): List<String> fun defaultHomeserverList(): List<String>
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
@@ -47,6 +48,11 @@ interface EnterpriseService {
*/ */
fun overriddenElementWellKnown(): ElementWellKnown? fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object { companion object {
const val ANY_ACCOUNT_PROVIDER = "*" const val ANY_ACCOUNT_PROVIDER = "*"
} }
@@ -0,0 +1,17 @@
/*
* 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.enterprise.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -0,0 +1,23 @@
/*
* 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.enterprise.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList() override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
} }
@@ -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.enterprise.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -5,10 +5,12 @@
* 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.scanner package io.element.android.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/** /**
@@ -16,5 +18,12 @@ import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
*/ */
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider { class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null) override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
} }
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() }, private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() }, private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() }, private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() }, private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() }, private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
) : EnterpriseService { ) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor) private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors) private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId) isEnterpriseUserResult(sessionId)
} }
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask { override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver) tweakMasUrlResult(url, homeserver, urlContentFetcher)
} }
override fun defaultHomeserverList(): List<String> { override fun defaultHomeserverList(): List<String> {
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
override fun overriddenElementWellKnown(): ElementWellKnown? { override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult() return overrideWellKnownResult()
} }
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
} }
@@ -0,0 +1,21 @@
/*
* 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.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -0,0 +1,17 @@
/*
* 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.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
}, },
// We want a 16dp left padding for the navigationIcon : // We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding // 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton) // 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets // 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp), windowInsets = WindowInsets(left = 4.dp),
) )
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton( val interactionSource = remember { MutableInteractionSource() }
modifier = modifier.then(testTag), Box(
onClick = onClick, modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) { ) {
Box {
val avatarData by remember(matrixUser) { val avatarData by remember(matrixUser) {
derivedStateOf { derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
} }
} }
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar( Avatar(
avatarData = avatarData, avatarData = avatarData,
avatarType = AvatarType.User, avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) { contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) { if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action) stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null null
}, },
) )
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) { if (showAvatarIndicator) {
RedIndicatorAtom( RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)
) )
} }
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
} }
} }
} }
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
override val filterSelectionStates = MutableStateFlow(buildFilters()) override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) { override fun select(filter: RoomListFilter) {
if (selectedFilters.any { it in filter.incompatibleFilters }) return
selectedFilters.add(filter) selectedFilters.add(filter)
filterSelectionStates.value = buildFilters() filterSelectionStates.value = buildFilters()
} }
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string> <string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string> <string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string> <string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string> <string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string> <string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string> <string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string> <string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string> <string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string> <string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string> <string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string> <string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string> <string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string> <string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string> <string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
} }
} }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Favourites, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.People, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
}
}
}
@Test @Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest { fun `present - clear filters event`() = runTest {
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string> <string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string> <string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
</resources> </resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"已是成員"</string> <string name="screen_invite_users_already_a_member">"已是成員"</string>
<string name="screen_invite_users_already_invited">"已邀請"</string> <string name="screen_invite_users_already_invited">"已邀請"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
</resources> </resources>
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice) private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
get() = linkDesktopStepFlow.asStateFlow() get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null private var currentJob: Job? = null
private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null private var handler: LinkDesktopHandler? = null
fun createNewHandler() { private fun resetJobs() {
currentJob?.cancel() currentJob?.cancel()
currentJob = null currentJob = null
timerJob?.cancel()
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull() handler = matrixClient.createLinkDesktopHandler().getOrNull()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
} }
fun reset() { fun reset() {
currentJob?.cancel() resetJobs()
currentJob = null
sessionScope.launch { sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
} }
} }
fun onScannedCode(data: ByteArray) { fun onScannedCode(data: ByteArray) {
currentJob?.cancel() resetJobs()
currentJob = null
val currentHandler = handler val currentHandler = handler
if (currentHandler == null) { if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.") Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
/**
* Max number of QrCode rotation.
* When QrCode is rotated this number of time, a TimeOut error is emitted.
* Set to 0 to disable QrCode rotation.
*/
private const val MAX_QR_CODE_ROTATION = 0
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
class ShowQrCodeNode( class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback() private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create( private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data, initialData = inputs.data,
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
) )
@Composable @Composable
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice) private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject @AssistedInject
class ShowQrCodePresenter( class ShowQrCodePresenter(
@Assisted private val initialData: String, @Assisted private val initialData: String,
@Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler, private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> { ) : Presenter<ShowQrCodeState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(initialData: String): ShowQrCodePresenter fun create(
initialData: String,
maxQrCodeRotation: Int,
): ShowQrCodePresenter
} }
private var loadingJob: Job? = null private var loadingJob: Job? = null
@Composable @Composable
override fun present(): ShowQrCodeState { override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) } var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState( val state by produceState(
initialValue = ShowQrCodeState( initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData), data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode() linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code // Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch { loadingJob = launch {
delay(1000) delay(1.seconds)
value = ShowQrCodeState( value = ShowQrCodeState(
data = AsyncData.Loading(), data = AsyncData.Loading(),
) )
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state return state
} }
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
} }
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string> <string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string> <string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string> <string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string> <string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string> <string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string> <string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string> <string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string> <string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string> <string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string> <string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string> <string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string> <string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string> <string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string> <string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string> <string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string> <string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string> <string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string> <string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string> <string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string> <string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string> <string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string> <string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string> <string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string> <string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string> <string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string> <string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
@@ -91,10 +91,36 @@ class ShowQrCodePresenterTest {
} }
} }
@Test
fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
maxQrCodeRotation = 0,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
expectNoEvents()
}
}
private fun createPresenter( private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter( ) = ShowQrCodePresenter(
initialData = "DATA", initialData = "DATA",
maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler, linkNewMobileHandler = linkNewMobileHandler,
) )
} }
@@ -29,14 +29,11 @@ internal class MapTilerTileServerStyleUriBuilder(
customMapStyleUrl: String?, customMapStyleUrl: String?,
darkMode: Boolean, darkMode: Boolean,
): String { ): String {
return buildString { return if (customMapStyleUrl.isNullOrBlank()) {
if (customMapStyleUrl.isNullOrBlank()) { val mapId = if (darkMode) darkMapId else lightMapId
val mapId = if (darkMode) darkMapId else lightMapId "$baseUrl/$mapId/style.json?key=$apiKey"
append("$baseUrl/$mapId/style.json") } else {
} else { customMapStyleUrl
append(customMapStyleUrl)
}
append("?key=$apiKey")
} }
} }
} }
@@ -46,7 +46,7 @@ class MapTilerTileServerStyleUriBuilderTest {
customMapStyleUrl = "https://custom.url/style.json", customMapStyleUrl = "https://custom.url/style.json",
darkMode = false, darkMode = false,
) )
).isEqualTo("https://custom.url/style.json?key=anApiKey") ).isEqualTo("https://custom.url/style.json")
} }
@Test @Test
@@ -56,6 +56,36 @@ class MapTilerTileServerStyleUriBuilderTest {
customMapStyleUrl = "https://custom.url/style.json", customMapStyleUrl = "https://custom.url/style.json",
darkMode = true, darkMode = true,
) )
).isEqualTo("https://custom.url/style.json?key=anApiKey") ).isEqualTo("https://custom.url/style.json")
}
@Test
fun `custom map uri with its own api key is not altered`() {
assertThat(
builder.build(
customMapStyleUrl = "https://api.maptiler.com/maps/streets-v2/style.json?key=anOperatorKey",
darkMode = false,
)
).isEqualTo("https://api.maptiler.com/maps/streets-v2/style.json?key=anOperatorKey")
}
@Test
fun `custom map uri with an existing query is not altered`() {
assertThat(
builder.build(
customMapStyleUrl = "https://self.hosted/style.json?foo=bar",
darkMode = true,
)
).isEqualTo("https://self.hosted/style.json?foo=bar")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
assertThat(
builder.build(
customMapStyleUrl = " ",
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
} }
} }
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string> <string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
</resources> </resources>
@@ -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">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string> <string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string> <string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
</resources> </resources>
@@ -59,7 +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 Timber.d("Setup biometric authentication")
try { try {
val secretKey = ensureKey() val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey) val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
if (this == null) return false if (this == null) return false
return runCatchingExceptions { return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray()) doFinal("biometric_challenge".toByteArray())
}.isSuccess }.onFailure {
Timber.e(it, "Failed to validate cipher")
}
.isSuccess
} }
} }
@@ -13,13 +13,15 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetriever
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl( class DefaultAccountProviderAccessControl(
private val enterpriseService: EnterpriseService, private val enterpriseService: EnterpriseService,
private val wellknownRetriever: WellknownRetriever, private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : AccountProviderAccessControl { ) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try { override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider( assertIsAllowedToConnectToAccountProvider(
@@ -38,14 +40,21 @@ class DefaultAccountProviderAccessControl(
) { ) {
if (enterpriseService.isEnterpriseBuild.not()) { if (enterpriseService.isEnterpriseBuild.not()) {
// Ensure that Element Pro is not required for this account provider // Ensure that Element Pro is not required for this account provider
val wellKnown = wellknownRetriever.getElementWellKnown( val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
baseUrl = accountProviderUrl.ensureProtocol(), temporaryMatrixClient.use {
).dataOrNull() val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
if (wellKnown?.enforceElementPro == true) { val wellKnown = wellknownRetriever.getElementWellKnown(
throw AccountProviderAccessException.NeedElementProException( host = accountProviderUrl,
unauthorisedAccountProviderTitle = title, // It's expected this is hardcoded to the well-known endpoint
applicationId = ELEMENT_PRO_APPLICATION_ID, source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
) ).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
} }
} }
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) { if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
@@ -14,6 +14,8 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl import io.element.android.libraries.core.uri.isValidUrl
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -34,8 +36,13 @@ class HomeserverResolver(
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow { fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext() val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim() val trimmedUserInput = userInput.trim()
if (trimmedUserInput.length < 4) return@flow val searchTerm = when {
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/") MatrixPatterns.isUserId(trimmedUserInput) -> UserId(trimmedUserInput).domainName.orEmpty()
trimmedUserInput.startsWith("@") -> return@flow
else -> trimmedUserInput
}
if (searchTerm.length < 4) return@flow
val candidateBase = searchTerm.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase) val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>()) val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel // Run all the requests in parallel
@@ -13,6 +13,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.AuthenticationConfig import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetriever
import timber.log.Timber import timber.log.Timber
@@ -22,24 +24,32 @@ interface WebClientUrlForAuthenticationRetriever {
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultWebClientUrlForAuthenticationRetriever( class DefaultWebClientUrlForAuthenticationRetriever(
private val wellknownRetriever: WellknownRetriever, private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : WebClientUrlForAuthenticationRetriever { ) : WebClientUrlForAuthenticationRetriever {
override suspend fun retrieve(homeServerUrl: String): String { override suspend fun retrieve(homeServerUrl: String): String {
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) { if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
Timber.w("Temporary account creation flow is only supported on matrix.org") Timber.w("Temporary account creation flow is only supported on matrix.org")
throw AccountCreationNotSupported() throw AccountCreationNotSupported()
} }
val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull() val temporaryMatrixClient = temporaryMatrixClientFactory.create(homeServerUrl).getOrThrow()
?: throw AccountCreationNotSupported() return temporaryMatrixClient.use {
val registrationHelperUrl = wellknown.registrationHelperUrl val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
return if (registrationHelperUrl != null) { val wellknown = wellknownRetriever.getElementWellKnown(
registrationHelperUrl.toUri() host = homeServerUrl,
.buildUpon() source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT
.appendQueryParameter("hs_url", homeServerUrl) ).dataOrNull() ?: throw AccountCreationNotSupported()
.build()
.toString() val registrationHelperUrl = wellknown.registrationHelperUrl
} else { if (registrationHelperUrl != null) {
throw AccountCreationNotSupported() registrationHelperUrl.toUri()
.buildUpon()
.appendQueryParameter("hs_url", homeServerUrl)
.build()
.toString()
} else {
throw AccountCreationNotSupported()
}
} }
} }
} }
@@ -37,7 +37,14 @@
<string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string> <string name="screen_login_subtitle">"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."</string>
<string name="screen_login_title">"Willkommen zurück!"</string> <string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string> <string name="screen_login_title_with_homeserver">"Anmelden bei %1$s"</string>
<string name="screen_missing_key_backup_open_element_classic">"Element Classic öffnen"</string>
<string name="screen_missing_key_backup_step_1">"Öffne Element Classic auf deinem Gerät"</string>
<string name="screen_missing_key_backup_step_2_android">"Geh zu Einstellungen &gt; Sicherheit &amp; Datenschutz"</string>
<string name="screen_missing_key_backup_step_4">"Folge den Anweisungen, um den Schlüsselspeicher zu aktivieren."</string>
<string name="screen_missing_key_backup_step_5">"Zurück zu %1$s"</string>
<string name="screen_missing_key_backup_title">"Aktiviere deinen Schlüsselspeicher, bevor du mit %1$s fortfährst."</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string> <string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_checking_account">"Konto wird geprüft"</string>
<string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string> <string name="screen_onboarding_sign_in_manually">"Manuell anmelden"</string>
<string name="screen_onboarding_sign_in_to">"Anmelden bei %1$s"</string> <string name="screen_onboarding_sign_in_to">"Anmelden bei %1$s"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string> <string name="screen_onboarding_sign_in_with_qr_code">"Mit QR-Code anmelden"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"Vad är adressen till din server?"</string> <string name="screen_change_server_subtitle">"Vad är adressen till din server?"</string>
<string name="screen_change_server_title">"Välj din server"</string> <string name="screen_change_server_title">"Välj din server"</string>
<string name="screen_create_account_title">"Skapa konto"</string> <string name="screen_create_account_title">"Skapa konto"</string>
<string name="screen_login_error_deactivated_account">"Detta konto har avaktiverats."</string> <string name="screen_login_error_deactivated_account">"Detta konto har raderats."</string>
<string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string> <string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string>
<string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string> <string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."</string> <string name="screen_login_error_refresh_tokens">"Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning."</string>
@@ -11,7 +11,7 @@
<string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string> <string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"</string> <string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"</string>
<string name="screen_change_account_provider_other">"其他"</string> <string name="screen_change_account_provider_other">"其他"</string>
<string name="screen_change_account_provider_subtitle">"使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。"</string> <string name="screen_change_account_provider_subtitle">"使用不同的帳號提供者,例如您自己的伺服器或工作帳號。"</string>
<string name="screen_change_account_provider_title">"更改帳號提供者"</string> <string name="screen_change_account_provider_title">"更改帳號提供者"</string>
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string> <string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s 需要 Element Pro 應用程式。請從商店下載。"</string> <string name="screen_change_server_error_element_pro_required_message">"%1$s 需要 Element Pro 應用程式。請從商店下載。"</string>
@@ -28,7 +28,7 @@
<string name="screen_change_server_subtitle">"您的伺服器地址?"</string> <string name="screen_change_server_subtitle">"您的伺服器地址?"</string>
<string name="screen_change_server_title">"選擇您的伺服器"</string> <string name="screen_change_server_title">"選擇您的伺服器"</string>
<string name="screen_create_account_title">"建立帳號"</string> <string name="screen_create_account_title">"建立帳號"</string>
<string name="screen_login_error_deactivated_account">"這個帳號已被停用。"</string> <string name="screen_login_error_deactivated_account">"此帳號已被刪除。"</string>
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string> <string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
<string name="screen_login_error_invalid_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string> <string name="screen_login_error_invalid_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string>
<string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string> <string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string>
@@ -12,10 +12,12 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.wellknown.test.FakeWellknownRetriever import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2 import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -156,15 +158,18 @@ class DefaultAccountProviderAccessControlTest {
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver }, isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders }, defaultHomeserverListResult = { allowedAccountProviders },
), ),
wellknownRetriever = FakeWellknownRetriever( wellknownRetrieverFactory = FakeWellknownRetrieverFactory(
getElementWellKnownResult = { wellknownRetriever = FakeWellknownRetriever(
if (elementWellKnown == null) { getElementWellKnownResult = { _, _ ->
WellknownRetrieverResult.NotFound if (elementWellKnown == null) {
} else { WellknownRetrieverResult.NotFound
WellknownRetrieverResult.Success(elementWellKnown) } else {
} WellknownRetrieverResult.Success(elementWellKnown)
}, }
},
),
), ),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
) )
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() { private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
@@ -17,18 +17,19 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.wellknown.test.FakeWellknownRetriever import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
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.aMatrixHomeServerDetails import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@@ -158,7 +159,8 @@ class ChangeServerPresenterTest {
@Test @Test
fun `present - change server element pro required error`() = runTest { fun `present - change server element pro required error`() = runTest {
val getElementWellKnownResult = lambdaRecorder<String, WellknownRetrieverResult<ElementWellKnown>> { val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource ->
assertThat(homeserver).isEqualTo(A_HOMESERVER_URL)
WellknownRetrieverResult.Success( WellknownRetrieverResult.Success(
anElementWellKnown( anElementWellKnown(
enforceElementPro = true, enforceElementPro = true,
@@ -174,8 +176,6 @@ class ChangeServerPresenterTest {
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL) val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider)) initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem() val failureState = awaitItem()
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
@@ -183,9 +183,7 @@ class ChangeServerPresenterTest {
assertThat( assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId (failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise") ).isEqualTo("io.element.enterprise")
getElementWellKnownResult.assertions() getElementWellKnownResult.assertions().isCalledOnce()
.isCalledOnce()
.with(value(A_HOMESERVER_URL.ensureProtocol()))
} }
} }
@@ -269,7 +267,8 @@ class ChangeServerPresenterTest {
accountProviderDataSource = accountProviderDataSource, accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever, wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
), ),
localNetworkPermissionGate = LocalNetworkPermissionGate( localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor, advisor = localNetworkPermissionAdvisor,
@@ -0,0 +1,108 @@
/*
* 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.login.impl.resolver
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.auth.FakeHomeServerLoginCompatibilityChecker
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class HomeserverResolverTest {
@Test
fun `resolve - a server name is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - an url is used as is`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("https://example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org")),
)
}
@Test
fun `resolve - a user id with a port resolves the server name of the user id`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(it == "https://example.org:8448") }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:example.org:8448").test {
assertThat(awaitItem()).isEqualTo(listOf(HomeserverData(homeserverUrl = "https://example.org:8448")))
awaitComplete()
}
checkResult.assertions().isCalledExactly(1)
.withSequence(
listOf(value("https://example.org:8448")),
)
}
@Test
fun `resolve - an incomplete user id does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice").test {
awaitComplete()
}
sut.resolve("@alice:").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
@Test
fun `resolve - a user id with a too short server name does not resolve anything`() = runTest {
val checkResult = lambdaRecorder<String, Result<Boolean>> { Result.success(true) }
val sut = createHomeserverResolver(checkResult)
sut.resolve("@alice:a.b").test {
awaitComplete()
}
checkResult.assertions().isNeverCalled()
}
private fun TestScope.createHomeserverResolver(
checkResult: (String) -> Result<Boolean>,
) = HomeserverResolver(
dispatchers = testCoroutineDispatchers(),
homeServerLoginCompatibilityChecker = FakeHomeServerLoginCompatibilityChecker(checkResult = checkResult),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
)
}
@@ -20,6 +20,7 @@ import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetriever import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -30,11 +31,15 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_LOGIN_HINT import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
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.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.oauth.api.OAuthActionFlow
import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow import io.element.android.libraries.oauth.test.customtab.FakeOAuthActionFlow
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.sessionstorage.api.SessionStore 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
@@ -304,7 +309,8 @@ private fun createPresenter(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService, enterpriseService = enterpriseService,
wellknownRetriever = wellknownRetriever, wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
), ),
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginModePresenter = loginModePresenter, loginModePresenter = loginModePresenter,
@@ -318,9 +324,9 @@ fun createLoginModePresenter(
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor = localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(), FakeLocalNetworkPermissionAdvisor(),
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory = permissionsPresenterFactory: PermissionsPresenter.Factory =
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(), FakePermissionsPresenterFactory(),
): LoginModePresenter = LoginModePresenter( ): LoginModePresenter = LoginModePresenter(
oAuthActionFlow = oAuthActionFlow, oAuthActionFlow = oAuthActionFlow,
authenticationService = authenticationService, authenticationService = authenticationService,
@@ -0,0 +1,39 @@
/*
* 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.messages.impl.pinned
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
internal fun List<MatrixTimelineItem>.keepDisplayablePinnedEvents(): List<MatrixTimelineItem> {
val items = filter { it.isDisplayableAsPinnedEvent() }
return items.filterIndexed { index, item ->
!item.isDayDivider() || items.getOrNull(index + 1) is MatrixTimelineItem.Event
}
}
internal fun EventContent.isDisplayableAsPinnedEvent(): Boolean {
return when (this) {
UnknownContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent -> false
else -> true
}
}
private fun MatrixTimelineItem.isDisplayableAsPinnedEvent(): Boolean {
return this !is MatrixTimelineItem.Event || event.content.isDisplayableAsPinnedEvent()
}
private fun MatrixTimelineItem.isDayDivider(): Boolean {
return this is MatrixTimelineItem.Virtual && virtual is VirtualTimelineItem.DayDivider
}
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.banner
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.pinned.isDisplayableAsPinnedEvent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -24,6 +25,7 @@ class PinnedMessagesBannerItemFactory(
when (timelineItem) { when (timelineItem) {
is MatrixTimelineItem.Event -> { is MatrixTimelineItem.Event -> {
val eventId = timelineItem.eventId ?: return@withContext null val eventId = timelineItem.eventId ?: return@withContext null
if (!timelineItem.event.content.isDisplayableAsPinnedEvent()) return@withContext null
val formatted = formatter.format(timelineItem.event) val formatted = formatter.format(timelineItem.event)
PinnedMessagesBannerItem( PinnedMessagesBannerItem(
eventId = eventId, eventId = eventId,
@@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.link.LinkState
import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider
import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig
@@ -207,7 +208,7 @@ class PinnedMessagesListPresenter(
val timelineItemsFlow = asyncTimeline.data.timelineItems val timelineItemsFlow = asyncTimeline.data.timelineItems
combine(timelineItemsFlow, room.membersStateFlow) { items, membersState -> combine(timelineItemsFlow, room.membersStateFlow) { items, membersState ->
timelineItemsFactory.replaceWith( timelineItemsFactory.replaceWith(
timelineItems = items, timelineItems = items.keepDisplayablePinnedEvents(),
roomMembers = membersState.roomMembers().orEmpty(), roomMembers = membersState.roomMembers().orEmpty(),
renderReadReceipts = false, renderReadReceipts = false,
) )
@@ -239,7 +240,7 @@ class PinnedMessagesListPresenter(
AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading
is AsyncData.Failure -> PinnedMessagesListState.Failed is AsyncData.Failure -> PinnedMessagesListState.Failed
is AsyncData.Success -> { is AsyncData.Success -> {
if (timelineItems.data.isEmpty()) { if (timelineItems.data.all { it is TimelineItem.Virtual }) {
PinnedMessagesListState.Empty PinnedMessagesListState.Empty
} else { } else {
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
@@ -28,6 +28,7 @@ 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.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.MessagesEvent
import io.element.android.features.messages.impl.MessagesNavigator import io.element.android.features.messages.impl.MessagesNavigator
import io.element.android.features.messages.impl.MessagesPresenter import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.MessagesState import io.element.android.features.messages.impl.MessagesState
@@ -40,6 +41,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -54,6 +56,7 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins), MessagesNavigator { ) : Node(buildContext, plugins = plugins), MessagesNavigator {
data class Inputs( data class Inputs(
val threadRootEventId: ThreadId, val threadRootEventId: ThreadId,
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
modifier = modifier, modifier = modifier,
knockRequestsBannerView = {}, knockRequestsBannerView = {},
customReactionBottomSheet = {}, customReactionBottomSheet = {
CustomReactionBottomSheet(
state = state.customReactionState,
onSelectEmoji = { uniqueId, emoji ->
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
},
emojiPickerRenderer = emojiPickerRenderer,
)
},
onThreadsListClick = {}, onThreadsListClick = {},
) )
@@ -125,6 +125,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName
import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources import io.element.android.libraries.matrix.api.timeline.item.event.mediaSources
import io.element.android.libraries.matrix.api.timeline.item.event.toMatrixUser
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
} }
fun onUserDataClick() { fun onUserDataClick() {
val sender = MatrixUser( val sender = event.senderProfile.toMatrixUser(event.senderId)
userId = event.senderId,
displayName = event.senderProfile.getDisplayName(),
avatarUrl = event.senderProfile.getAvatarUrl(),
)
onUserDataClick(sender) onUserDataClick(sender)
} }
@@ -804,15 +801,15 @@ private fun MessageEventBubbleContent(
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(shape) .clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) { val talkbackCompatModifier = when {
// Use z-index to make the replied to text being read after the message // Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason // Usually, you'd use traversalIndex for that, but it's not working for some reason
inReplyToModifier.zIndex(1f) isTalkbackActive() -> inReplyToModifier.zIndex(1f)
} else { inReplyTo is InReplyToDetails.Error -> inReplyToModifier
inReplyToModifier.clickable(onClick = inReplyToClick) else -> inReplyToModifier.clickable(onClick = inReplyToClick)
} }
val contentHasError = currentContentValidationState.hasError() val contentHasError = currentContentValidationState.hasError() || inReplyTo is InReplyToDetails.Error
val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary val borderColor = if (contentHasError) ElementTheme.colors.borderCriticalSubtle else ElementTheme.colors.separatorPrimary
val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault val backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
Box( Box(
@@ -8,11 +8,16 @@
package io.element.android.features.messages.impl.timeline.components.customreaction package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
modifier = modifier, modifier = modifier,
scrollable = false, scrollable = false,
) { ) {
ExpandSheetWhenImeIsVisible(sheetState)
emojiPickerRenderer.Render( emojiPickerRenderer.Render(
state = state.target.emojiPickerState, state = state.target.emojiPickerState,
onSelectEmoji = ::onEmojiSelectedDismiss, onSelectEmoji = ::onEmojiSelectedDismiss,
@@ -67,3 +73,14 @@ fun CustomReactionBottomSheet(
} }
} }
} }
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
internal fun ExpandSheetWhenImeIsVisible(sheetState: SheetState) {
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible) {
if (isImeVisible) {
sheetState.expand()
}
}
}
@@ -58,10 +58,12 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean =
is TimelineItemFileContent, is TimelineItemFileContent,
is TimelineItemAudioContent, is TimelineItemAudioContent,
is TimelineItemVideoContent, is TimelineItemVideoContent,
is TimelineItemLocationContent,
is TimelineItemVoiceContent, is TimelineItemVoiceContent,
is TimelineItemGalleryContent, is TimelineItemGalleryContent,
is TimelineItemAttachmentsContent -> true is TimelineItemAttachmentsContent -> true
// Live location shares can't be forwarded, the SDK rejects them, so we only show the option for static locations
// See https://github.com/element-hq/element-x-android/issues/7190
is TimelineItemLocationContent -> mode is TimelineItemLocationContent.Mode.Static
// Stickers can't be forwarded (yet) so we don't show the option // Stickers can't be forwarded (yet) so we don't show the option
// See https://github.com/element-hq/element-x-android/issues/2161 // See https://github.com/element-hq/element-x-android/issues/2161
is TimelineItemStickerContent -> false is TimelineItemStickerContent -> false
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
@@ -20,7 +19,6 @@ import io.element.android.features.contentscanner.api.ContentScannerService
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaPreviewService import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.isPreviewEnabled import io.element.android.libraries.matrix.api.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
private val mediaPreviewService: MediaPreviewService, private val mediaPreviewService: MediaPreviewService,
private val room: BaseRoom, private val room: BaseRoom,
private val contentScannerService: ContentScannerService, private val contentScannerService: ContentScannerService,
private val timelineProtectionStore: TimelineProtectionStore,
) : Presenter<TimelineProtectionState> { ) : Presenter<TimelineProtectionState> {
private val allowedEvents = mutableStateSetOf<EventId>()
@Composable @Composable
override fun present(): TimelineProtectionState { override fun present(): TimelineProtectionState {
val mediaPreviewValue = remember { val mediaPreviewValue = remember {
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
if (isPreviewEnabled) { if (isPreviewEnabled) {
ProtectionState.RenderAll ProtectionState.RenderAll
} else { } else {
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet()) ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
} }
} }
} }
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
fun handleEvent(event: TimelineProtectionEvent) { fun handleEvent(event: TimelineProtectionEvent) {
when (event) { when (event) {
is TimelineProtectionEvent.ShowContent -> { is TimelineProtectionEvent.ShowContent -> {
allowedEvents += setOfNotNull(event.eventId) event.eventId?.let(timelineProtectionStore::allowEvent)
} }
is TimelineProtectionEvent.ValidateContent -> { is TimelineProtectionEvent.ValidateContent -> {
contentScannerService.scan(event.mediaSources, event.validationState) contentScannerService.scan(event.mediaSources, event.validationState)
@@ -0,0 +1,32 @@
/*
* 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.messages.impl.timeline.protection
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.snapshots.SnapshotStateSet
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
interface TimelineProtectionStore {
val allowedEventIds: SnapshotStateSet<EventId>
fun allowEvent(eventId: EventId)
}
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultTimelineProtectionStore : TimelineProtectionStore {
private val allowedEvents = mutableStateSetOf<EventId>()
override val allowedEventIds: SnapshotStateSet<EventId> = allowedEvents
override fun allowEvent(eventId: EventId) {
allowedEvents += eventId
}
}
@@ -70,6 +70,10 @@
<string name="screen_room_timeline_reactions_show_more">"Több megjelenítése"</string> <string name="screen_room_timeline_reactions_show_more">"Több megjelenítése"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Reakció-összefoglaló megjelenítése"</string> <string name="screen_room_timeline_reactions_show_reactions_summary">"Reakció-összefoglaló megjelenítése"</string>
<string name="screen_room_timeline_read_marker_title">"Új"</string> <string name="screen_room_timeline_read_marker_title">"Új"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d eltávolított üzenet"</item>
<item quantity="other">"%1$d eltávolított üzenet"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d szobaváltozás"</item> <item quantity="one">"%1$d szobaváltozás"</item>
<item quantity="other">"%1$d szobaváltozás"</item> <item quantity="other">"%1$d szobaváltozás"</item>
@@ -32,7 +32,7 @@
<string name="screen_media_upload_preview_processing">"Przetwarzanie…"</string> <string name="screen_media_upload_preview_processing">"Przetwarzanie…"</string>
<string name="screen_media_upload_preview_title">"Dodaj media"</string> <string name="screen_media_upload_preview_title">"Dodaj media"</string>
<string name="screen_report_content_block_user">"Zablokuj użytkownika"</string> <string name="screen_report_content_block_user">"Zablokuj użytkownika"</string>
<string name="screen_report_content_block_user_hint">"Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string> <string name="screen_report_content_block_user_hint">"Zaznacz, jeśli chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika."</string>
<string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string> <string name="screen_report_content_explanation">"Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości."</string>
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string> <string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string> <string name="screen_room_attachment_source_camera">"Kamera"</string>
@@ -7,6 +7,19 @@
<string name="crypto_event_authenticity_unknown_device">"Criptat de un dispozitiv necunoscut sau șters."</string> <string name="crypto_event_authenticity_unknown_device">"Criptat de un dispozitiv necunoscut sau șters."</string>
<string name="crypto_event_authenticity_unsigned_device">"Criptat de un dispozitiv care nu este verificat de proprietarul său."</string> <string name="crypto_event_authenticity_unsigned_device">"Criptat de un dispozitiv care nu este verificat de proprietarul său."</string>
<string name="crypto_event_authenticity_unverified_identity">"Criptat de un utilizator neverificat."</string> <string name="crypto_event_authenticity_unverified_identity">"Criptat de un utilizator neverificat."</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Răsturnați imaginea pe orizontală"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Răsturnat pe orizontală"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Răsturnați imaginea pe verticală"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Răsturnat pe verticală"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotiți imaginea la stânga"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d grad"</item>
<item quantity="few">"%1$d grade"</item>
<item quantity="other">"%1$d grade"</item>
</plurals>
<string name="screen_image_edition_title">"Editați poza"</string>
<string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string> <string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Atingeți pentru a modifica calitatea încărcării videoclipului"</string> <string name="screen_media_upload_preview_change_video_quality_prompt">"Atingeți pentru a modifica calitatea încărcării videoclipului"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Fișierul nu a putut fi încărcat."</string> <string name="screen_media_upload_preview_error_could_not_be_uploaded">"Fișierul nu a putut fi încărcat."</string>
@@ -17,6 +30,7 @@
<string name="screen_media_upload_preview_item_count">"Elementul %1$d din %2$d"</string> <string name="screen_media_upload_preview_item_count">"Elementul %1$d din %2$d"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"Optimizați calitatea imaginii"</string> <string name="screen_media_upload_preview_optimize_image_quality_title">"Optimizați calitatea imaginii"</string>
<string name="screen_media_upload_preview_processing">"Se procesează…"</string> <string name="screen_media_upload_preview_processing">"Se procesează…"</string>
<string name="screen_media_upload_preview_title">"Adăugați media"</string>
<string name="screen_report_content_block_user">"Blocați utilizatorul"</string> <string name="screen_report_content_block_user">"Blocați utilizatorul"</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string> <string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string> <string name="screen_report_content_explanation">"Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat."</string>
@@ -59,6 +73,11 @@
<string name="screen_room_timeline_reactions_show_more">"Afișați mai mult"</string> <string name="screen_room_timeline_reactions_show_more">"Afișați mai mult"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"Afișați rezumatul reacțiilor"</string> <string name="screen_room_timeline_reactions_show_reactions_summary">"Afișați rezumatul reacțiilor"</string>
<string name="screen_room_timeline_read_marker_title">"Nou"</string> <string name="screen_room_timeline_read_marker_title">"Nou"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="one">"%1$d mesaj șters"</item>
<item quantity="few">"%1$d mesaje șterse"</item>
<item quantity="other">"%1$d mesaje șterse"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="one">"%1$d schimbare a camerii"</item> <item quantity="one">"%1$d schimbare a camerii"</item>
<item quantity="few">"%1$d schimbări ale camerei"</item> <item quantity="few">"%1$d schimbări ale camerei"</item>
@@ -7,6 +7,17 @@
<string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string> <string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string>
<string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string> <string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string>
<string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string> <string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻轉圖片"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻轉"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始狀態"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"垂直翻轉圖片"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"垂直翻轉"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"原始狀態"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"向左旋轉圖片"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="other">"%1$d 度"</item>
</plurals>
<string name="screen_image_edition_title">"編輯照片"</string>
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string> <string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string> <string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string> <string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string>
@@ -17,6 +28,7 @@
<string name="screen_media_upload_preview_item_count">"第 %1$d 個項目,共 %2$d 個"</string> <string name="screen_media_upload_preview_item_count">"第 %1$d 個項目,共 %2$d 個"</string>
<string name="screen_media_upload_preview_optimize_image_quality_title">"最佳化影像品質"</string> <string name="screen_media_upload_preview_optimize_image_quality_title">"最佳化影像品質"</string>
<string name="screen_media_upload_preview_processing">"正在處理……"</string> <string name="screen_media_upload_preview_processing">"正在處理……"</string>
<string name="screen_media_upload_preview_title">"新增媒體"</string>
<string name="screen_report_content_block_user">"封鎖使用者"</string> <string name="screen_report_content_block_user">"封鎖使用者"</string>
<string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string> <string name="screen_report_content_block_user_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</string>
<string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string> <string name="screen_report_content_explanation">"此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。"</string>
@@ -55,6 +67,9 @@
<string name="screen_room_timeline_reactions_show_more">"更多"</string> <string name="screen_room_timeline_reactions_show_more">"更多"</string>
<string name="screen_room_timeline_reactions_show_reactions_summary">"顯示反應摘要"</string> <string name="screen_room_timeline_reactions_show_reactions_summary">"顯示反應摘要"</string>
<string name="screen_room_timeline_read_marker_title">"新訊息"</string> <string name="screen_room_timeline_read_marker_title">"新訊息"</string>
<plurals name="screen_room_timeline_redacted_messages">
<item quantity="other">"%1$d 則已移除的訊息"</item>
</plurals>
<plurals name="screen_room_timeline_state_changes"> <plurals name="screen_room_timeline_state_changes">
<item quantity="other">"%1$d 個聊天室變更"</item> <item quantity="other">"%1$d 個聊天室變更"</item>
</plurals> </plurals>
@@ -21,7 +21,10 @@ import io.element.android.features.messages.impl.timeline.model.event.RtcNotific
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aLiveLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
@@ -601,6 +604,123 @@ class ActionListPresenterTest {
} }
} }
@Test
fun `present - compute for a static location item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemLocationContent(mode = aStaticLocationMode()),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
),
recentEmojis = suggestedEmojis,
)
)
initialState.eventSink.invoke(ActionListEvent.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for a live location item does not offer to forward it`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemLocationContent(mode = aLiveLocationMode(isActive = true)),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
event = messageEvent,
sentTimeFull = "0 Full true",
displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.CopyLink,
TimelineItemAction.Pin,
TimelineItemAction.ViewSource,
TimelineItemAction.Redact,
),
recentEmojis = suggestedEmojis,
)
)
initialState.eventSink.invoke(ActionListEvent.Clear)
assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None)
}
}
@Test
fun `present - compute for an ended live location item does not offer to forward it`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
presenter.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
isEditable = false,
content = aTimelineItemLocationContent(mode = aLiveLocationMode(isActive = false)),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
event = messageEvent,
userEventPermissions = aUserEventPermissions(
canRedactOwn = true,
canRedactOther = false,
canSendMessage = true,
canSendReaction = true,
canPinUnpin = true,
),
)
)
val successState = awaitItem()
assertThat((successState.target as ActionListState.Target.Success).actions)
.doesNotContain(TimelineItemAction.Forward)
}
}
@Test @Test
fun `present - compute for a media with caption item`() = runTest { fun `present - compute for a media with caption item`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = true) val presenter = createActionListPresenter(isDeveloperModeEnabled = true)
@@ -0,0 +1,74 @@
/*
* 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.messages.impl.pinned.banner
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PinnedMessagesBannerItemFactoryTest {
@Test
fun `create - returns an item for an event which can be displayed`() = runTest {
val factory = createPinnedMessagesBannerItemFactory()
val item = factory.create(anEventTimelineItemOf(aMessageContent("A message")))
assertThat(item?.eventId).isEqualTo(AN_EVENT_ID)
}
@Test
fun `create - returns null for events which cannot be displayed`() = runTest {
val factory = createPinnedMessagesBannerItemFactory()
val contents = listOf(
UnknownContent,
FailedToParseMessageLikeContent(eventType = "m.room.message", error = "an error"),
FailedToParseStateContent(eventType = "m.room.topic", stateKey = "", error = "an error"),
)
for (content in contents) {
assertThat(factory.create(anEventTimelineItemOf(content))).isNull()
}
}
@Test
fun `create - returns null for virtual items`() = runTest {
val factory = createPinnedMessagesBannerItemFactory()
val item = factory.create(
MatrixTimelineItem.Virtual(
uniqueId = A_UNIQUE_ID,
virtual = VirtualTimelineItem.DayDivider(timestamp = 0L),
)
)
assertThat(item).isNull()
}
private fun anEventTimelineItemOf(content: EventContent) = MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = content,
),
)
private fun TestScope.createPinnedMessagesBannerItemFactory() = PinnedMessagesBannerItemFactory(
coroutineDispatchers = testCoroutineDispatchers(),
formatter = FakePinnedMessagesBannerFormatter(
formatLambda = { event -> "${event.content}" }
),
)
}
@@ -14,6 +14,7 @@ import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBanner
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID
@@ -24,6 +25,7 @@ import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -90,6 +92,46 @@ class PinnedMessagesBannerPresenterTest {
} }
} }
@Test
fun `present - loaded state - events which cannot be displayed are not rendered`() = runTest {
val messageContent = aMessageContent("A message")
val pinnedEventsTimeline = FakeTimeline(
timelineItems = flowOf(
listOf(
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = messageContent,
),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = UnknownContent,
),
)
)
)
)
val room = FakeJoinedRoom(
createTimelineResult = { Result.success(pinnedEventsTimeline) }
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
}
val presenter = createPinnedMessagesBannerPresenter(room = room)
presenter.test {
val loadedState = consumeItemsUntilPredicate { state ->
state is PinnedMessagesBannerState.Loaded
}.last() as PinnedMessagesBannerState.Loaded
assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0)
assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString())
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - loaded state - multiple pinned messages`() = runTest { fun `present - loaded state - multiple pinned messages`() = runTest {
val messageContent1 = aMessageContent("A message") val messageContent1 = aMessageContent("A message")
@@ -21,13 +21,19 @@ import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvi
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_UNIQUE_ID import io.element.android.libraries.matrix.test.A_UNIQUE_ID
import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2
import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
@@ -38,6 +44,7 @@ import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.services.analytics.api.AnalyticsService 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.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
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
@@ -131,6 +138,90 @@ class PinnedMessagesListPresenterTest {
} }
} }
@Test
fun `present - filled state - events which cannot be displayed are not rendered`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline(
items = listOf(
MatrixTimelineItem.Virtual(
uniqueId = UniqueId("aDayDivider"),
virtual = VirtualTimelineItem.DayDivider(timestamp = 0L),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = aMessageContent("A message"),
),
),
MatrixTimelineItem.Virtual(
uniqueId = UniqueId("anotherDayDivider"),
virtual = VirtualTimelineItem.DayDivider(timestamp = 1L),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID_2,
content = UnknownContent,
),
),
),
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2)))
},
createTimelineResult = { Result.success(pinnedEventsTimeline) },
)
val presenter = createPinnedMessagesListPresenter(room = room)
presenter.test {
val filledState = consumeItemsUntilPredicate { state ->
state is PinnedMessagesListState.Filled
}.last() as PinnedMessagesListState.Filled
assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1)
assertThat(filledState.timelineItems).hasSize(2)
assertThat(filledState.timelineItems.filterIsInstance<TimelineItem.Event>().map { it.eventId }).containsExactly(AN_EVENT_ID)
assertThat(filledState.timelineItems.filterIsInstance<TimelineItem.Virtual>()).hasSize(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - empty state when no pinned event can be displayed`() = runTest {
val pinnedEventsTimeline = createPinnedMessagesTimeline(
items = listOf(
MatrixTimelineItem.Virtual(
uniqueId = A_UNIQUE_ID,
virtual = VirtualTimelineItem.DayDivider(timestamp = 0L),
),
MatrixTimelineItem.Event(
uniqueId = A_UNIQUE_ID_2,
event = anEventTimelineItem(
eventId = AN_EVENT_ID,
content = FailedToParseMessageLikeContent(eventType = "m.room.message", error = "an error"),
),
),
),
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
).apply {
givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID)))
},
createTimelineResult = { Result.success(pinnedEventsTimeline) },
)
val presenter = createPinnedMessagesListPresenter(room = room)
presenter.test {
val emptyState = consumeItemsUntilPredicate { state ->
state is PinnedMessagesListState.Empty
}.last()
assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - unpin event`() = runTest { fun `present - unpin event`() = runTest {
val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) }
@@ -263,21 +354,18 @@ class PinnedMessagesListPresenterTest {
} }
} }
private fun createPinnedMessagesTimeline(): FakeTimeline { private fun createPinnedMessagesTimeline(
val messageContent = aMessageContent("A message") items: List<MatrixTimelineItem> = listOf(
return FakeTimeline( MatrixTimelineItem.Event(
timelineItems = flowOf( uniqueId = A_UNIQUE_ID,
listOf( event = anEventTimelineItem(
MatrixTimelineItem.Event( eventId = AN_EVENT_ID,
uniqueId = A_UNIQUE_ID, content = aMessageContent("A message"),
event = anEventTimelineItem( ),
eventId = AN_EVENT_ID,
content = messageContent,
),
)
)
) )
) ),
): FakeTimeline {
return FakeTimeline(timelineItems = flowOf(items))
} }
private fun roomPermissions( private fun roomPermissions(
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity 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.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
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.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
} }
@Test
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetailsReady(),
eventContent = MessageContent(
body = "Replied message",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("Replied message", null),
),
textContent = "Replied message",
),
),
),
eventSink = eventsRecorder,
),
)
eventsRecorder.clear()
onAllNodesWithText("Replied message").onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
}
@Test
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
),
),
eventSink = eventsRecorder,
),
onMessageClick = {},
)
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
eventsRecorder.assertEmpty()
}
@Ignore( @Ignore(
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." + "performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
"This needs to be reworked to use a different approach to check the LoadMore event was emitted." "This needs to be reworked to use a different approach to check the LoadMore event was emitted."
@@ -0,0 +1,62 @@
/*
* 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(ExperimentalMaterial3Api::class, ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline.components.customreaction
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class ExpandSheetWhenImeIsVisibleTest : RobolectricTest() {
@Test
fun `the sheet is left alone while the keyboard is hidden`() = runAndroidComposeUiTest<ComponentActivity> {
val sheetState = renderWithIme(isImeVisible = false)
assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded)
}
@Test
fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest<ComponentActivity> {
val sheetState = renderWithIme(isImeVisible = true)
assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.renderWithIme(isImeVisible: Boolean): SheetState {
lateinit var sheetState: SheetState
lateinit var view: View
setContent {
view = LocalView.current
sheetState = rememberBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
ExpandSheetWhenImeIsVisible(sheetState)
}
runOnIdle {
ViewCompat.dispatchApplyWindowInsets(
view,
WindowInsetsCompat.Builder()
.setVisible(WindowInsetsCompat.Type.ime(), isImeVisible)
.setInsets(WindowInsetsCompat.Type.ime(), if (isImeVisible) Insets.of(0, 0, 0, 300) else Insets.NONE)
.build()
)
}
waitForIdle()
return sheetState
}
@@ -95,6 +95,24 @@ class TimelineProtectionPresenterTest {
} }
} }
@Test
fun `present - shown content is restored when the presenter is recreated`() = runTest {
val mediaPreviewConfig = MediaPreviewConfig(mediaPreviewValue = MediaPreviewValue.Off, hideInviteAvatar = false)
val mediaPreviewService = FakeMediaPreviewService(mediaPreviewConfigFlow = MutableStateFlow(mediaPreviewConfig))
val timelineProtectionStore = DefaultTimelineProtectionStore()
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf()))
initialState.eventSink(TimelineProtectionEvent.ShowContent(eventId = AN_EVENT_ID))
val finalState = awaitItem()
assertThat(finalState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
createPresenter(mediaPreviewService = mediaPreviewService, timelineProtectionStore = timelineProtectionStore).test {
val initialState = awaitItem()
assertThat(initialState.protectionState).isEqualTo(ProtectionState.RenderOnly(persistentSetOf(AN_EVENT_ID)))
}
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - validate media scans the media source`() = runTest { fun `present - validate media scans the media source`() = runTest {
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
room: BaseRoom = FakeBaseRoom(), room: BaseRoom = FakeBaseRoom(),
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> }, contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
) = TimelineProtectionPresenter( ) = TimelineProtectionPresenter(
mediaPreviewService = mediaPreviewService, mediaPreviewService = mediaPreviewService,
room = room, room = room,
contentScannerService = contentScannerService, contentScannerService = contentScannerService,
timelineProtectionStore = timelineProtectionStore,
) )
} }
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
sealed interface BlockedUsersEvents { sealed interface BlockedUsersEvents {
data class Unblock(val userId: UserId) : BlockedUsersEvents data class Unblock(val userId: UserId) : BlockedUsersEvents
data class CopyToClipboard(val userId: UserId) : BlockedUsersEvents
data object ConfirmUnblock : BlockedUsersEvents data object ConfirmUnblock : BlockedUsersEvents
data object Cancel : BlockedUsersEvents data object Cancel : BlockedUsersEvents
} }
@@ -18,14 +18,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -34,6 +39,8 @@ import kotlinx.coroutines.launch
class BlockedUsersPresenter( class BlockedUsersPresenter(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val clipboardHelper: ClipboardHelper,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<BlockedUsersState> { ) : Presenter<BlockedUsersState> {
@Composable @Composable
override fun present(): BlockedUsersState { override fun present(): BlockedUsersState {
@@ -65,12 +72,18 @@ class BlockedUsersPresenter(
} }
} }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
fun handleEvent(event: BlockedUsersEvents) { fun handleEvent(event: BlockedUsersEvents) {
when (event) { when (event) {
is BlockedUsersEvents.Unblock -> { is BlockedUsersEvents.Unblock -> {
pendingUserToUnblock = event.userId pendingUserToUnblock = event.userId
unblockUserAction.value = AsyncAction.ConfirmingNoParams unblockUserAction.value = AsyncAction.ConfirmingNoParams
} }
is BlockedUsersEvents.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.userId.value)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
BlockedUsersEvents.ConfirmUnblock -> { BlockedUsersEvents.ConfirmUnblock -> {
pendingUserToUnblock?.let { pendingUserToUnblock?.let {
coroutineScope.unblockUser(it, unblockUserAction) coroutineScope.unblockUser(it, unblockUserAction)
@@ -86,6 +99,7 @@ class BlockedUsersPresenter(
return BlockedUsersState( return BlockedUsersState(
blockedUsers = ignoredMatrixUser.toImmutableList(), blockedUsers = ignoredMatrixUser.toImmutableList(),
unblockUserAction = unblockUserAction.value, unblockUserAction = unblockUserAction.value,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -9,11 +9,13 @@
package io.element.android.features.preferences.impl.blockedusers package io.element.android.features.preferences.impl.blockedusers
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class BlockedUsersState( data class BlockedUsersState(
val blockedUsers: ImmutableList<MatrixUser>, val blockedUsers: ImmutableList<MatrixUser>,
val unblockUserAction: AsyncAction<Unit>, val unblockUserAction: AsyncAction<Unit>,
val snackbarMessage: SnackbarMessage?,
val eventSink: (BlockedUsersEvents) -> Unit, val eventSink: (BlockedUsersEvents) -> Unit,
) )
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -30,11 +31,13 @@ class BlockedUsersStateProvider : PreviewParameterProvider<BlockedUsersState> {
internal fun aBlockedUsersState( internal fun aBlockedUsersState(
blockedUsers: List<MatrixUser> = aMatrixUserList(), blockedUsers: List<MatrixUser> = aMatrixUserList(),
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized, unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
snackbarMessage: SnackbarMessage? = null,
eventSink: (BlockedUsersEvents) -> Unit = {}, eventSink: (BlockedUsersEvents) -> Unit = {},
): BlockedUsersState { ): BlockedUsersState {
return BlockedUsersState( return BlockedUsersState(
blockedUsers = blockedUsers.toImmutableList(), blockedUsers = blockedUsers.toImmutableList(),
unblockUserAction = unblockUserAction, unblockUserAction = unblockUserAction,
snackbarMessage = snackbarMessage,
eventSink = eventSink, eventSink = eventSink,
) )
} }
@@ -8,7 +8,7 @@
package io.element.android.features.preferences.impl.blockedusers package io.element.android.features.preferences.impl.blockedusers
import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
@@ -31,6 +31,8 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.MatrixUserRow
@@ -43,6 +45,7 @@ fun BlockedUsersView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Box(modifier = modifier) { Box(modifier = modifier) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -52,7 +55,8 @@ fun BlockedUsersView(
BackButton(onClick = onBackClick) BackButton(onClick = onBackClick)
} }
) )
} },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)
@@ -60,7 +64,8 @@ fun BlockedUsersView(
items(state.blockedUsers) { matrixUser -> items(state.blockedUsers) { matrixUser ->
BlockedUserItem( BlockedUserItem(
matrixUser = matrixUser, matrixUser = matrixUser,
onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) } onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) },
onLongClick = { state.eventSink(BlockedUsersEvents.CopyToClipboard(it)) },
) )
} }
} }
@@ -107,9 +112,14 @@ fun BlockedUsersView(
private fun BlockedUserItem( private fun BlockedUserItem(
matrixUser: MatrixUser, matrixUser: MatrixUser,
onClick: (UserId) -> Unit, onClick: (UserId) -> Unit,
onLongClick: (UserId) -> Unit,
) { ) {
MatrixUserRow( MatrixUserRow(
modifier = Modifier.clickable { onClick(matrixUser.userId) }, modifier = Modifier.combinedClickable(
onClick = { onClick(matrixUser.userId) },
onLongClick = { onLongClick(matrixUser.userId) },
onLongClickLabel = stringResource(CommonStrings.action_copy),
),
matrixUser = matrixUser, matrixUser = matrixUser,
) )
} }
@@ -74,8 +74,14 @@ class PreferencesRootPresenter(
val showLinkNewDevice by remember { val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false) }.collectAsState(initial = false)
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null val isUserStatusSupported by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus)
.collect { isUserStatusEnabled ->
value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false)
}
}
val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null
val otherSessions by remember { val otherSessions by remember {
sessionStore.sessionsFlow().map { list -> sessionStore.sessionsFlow().map { list ->
@@ -8,10 +8,14 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -25,7 +29,10 @@ import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -71,71 +78,87 @@ fun PreferencesRootView(
) { ) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
// Include pref from other modules Box(modifier = modifier) {
PreferencePage( // Include pref from other modules
modifier = modifier, PreferencePage(
onBackClick = onBackClick, onBackClick = onBackClick,
title = stringResource(id = CommonStrings.common_settings), title = stringResource(id = CommonStrings.common_settings),
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
) { ) {
UserPreferences( UserPreferences(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onOpenUserProfile(state.myUser) onOpenUserProfile(state.myUser)
}, },
matrixUser = state.myUser, matrixUser = state.myUser,
)
if (state.isMultiAccountEnabled) {
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
) )
} if (state.isMultiAccountEnabled) {
if (state.userStatusState != null) { MultiAccountSection(
UserStatusSection( state = state,
userStatusState = state.userStatusState, onAddAccountClick = onAddAccountClick,
emojiPickerRenderer = emojiPickerRenderer, )
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
} }
) if (state.userStatusState != null) {
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
state = state,
onManageAccountClick = onManageAccountClick,
onLinkNewDeviceClick = onLinkNewDeviceClick,
onOpenBlockedUsers = onOpenBlockedUsers
)
// 'Manage my app' section
ManageAppSection(
state = state,
onOpenNotificationSettings = onOpenNotificationSettings,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onSecureBackupClick = onSecureBackupClick,
)
// General section
GeneralSection(
state = state,
onOpenAbout = onOpenAbout,
onOpenAnalytics = onOpenAnalytics,
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
// Version
Footer(
version = state.version,
onClick = if (!state.showDeveloperSettings) {
{ state.eventSink(PreferencesRootEvent.OnVersionInfoClick) }
} else {
null
}
)
}
state.userStatusState?.let {
UserStatusUpdateIndicator(it.updateStatusAction)
}
} }
} }
@Composable
private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction<Unit>) {
AsyncActionIndicator(
asyncAction = updateStatusAction,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding(),
loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) },
failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) },
)
}
@Composable @Composable
private fun ColumnScope.UserStatusSection( private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState, userStatusState: UserStatusState,
@@ -315,7 +338,9 @@ private fun ColumnScope.GeneralSection(
) )
} }
// Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry // Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry
if (state.showDeveloperSettings) { AnimatedVisibility(
visible = state.showDeveloperSettings,
) {
DeveloperPreferencesView(onOpenDeveloperSettings) DeveloperPreferencesView(onOpenDeveloperSettings)
} }
} }
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
@@ -38,6 +40,7 @@ class UserStatusPresenter(
var isEmojiPickerVisible by remember { mutableStateOf(false) } var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState() val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val updateStatusAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: UserStatusEvent) { fun handleEvent(event: UserStatusEvent) {
when (event) { when (event) {
@@ -47,11 +50,15 @@ class UserStatusPresenter(
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> { is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) } coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) }
}
} }
UserStatusEvent.ClearStatus -> { UserStatusEvent.ClearStatus -> {
pickerState = UserStatusPickerState.Hidden pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() } coroutineScope.launch {
updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() }
}
} }
UserStatusEvent.OpenCustomInput -> { UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus val raw = userProfile.rawStatus
@@ -93,6 +100,7 @@ class UserStatusPresenter(
displayedStatus = userProfile.displayedStatus, displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus, rawStatus = userProfile.rawStatus,
pickerState = effectivePickerState, pickerState = effectivePickerState,
updateStatusAction = updateStatusAction.value,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.api.picker.EmojiPickerState import io.element.android.libraries.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -19,6 +20,7 @@ data class UserStatusState(
val displayedStatus: DisplayedStatus?, val displayedStatus: DisplayedStatus?,
val rawStatus: UserStatus?, val rawStatus: UserStatus?,
val pickerState: UserStatusPickerState, val pickerState: UserStatusPickerState,
val updateStatusAction: AsyncAction<Unit>,
val eventSink: (UserStatusEvent) -> Unit, val eventSink: (UserStatusEvent) -> Unit,
) )
@@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
@@ -44,10 +45,12 @@ fun aUserStatusState(
displayedStatus: DisplayedStatus? = null, displayedStatus: DisplayedStatus? = null,
rawStatus: UserStatus? = null, rawStatus: UserStatus? = null,
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
updateStatusAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (UserStatusEvent) -> Unit = {}, eventSink: (UserStatusEvent) -> Unit = {},
) = UserStatusState( ) = UserStatusState(
displayedStatus = displayedStatus, displayedStatus = displayedStatus,
rawStatus = rawStatus, rawStatus = rawStatus,
pickerState = pickerState, pickerState = pickerState,
updateStatusAction = updateStatusAction,
eventSink = eventSink, eventSink = eventSink,
) )
@@ -11,6 +11,12 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Avatare in Chateinladungen ausblenden"</string> <string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"Avatare in Chateinladungen ausblenden"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Medienvorschau im Nachrichtenverlauf ausblenden"</string> <string name="screen_advanced_settings_hide_timeline_media_toggle_title">"Medienvorschau im Nachrichtenverlauf ausblenden"</string>
<string name="screen_advanced_settings_labs">"Labs"</string> <string name="screen_advanced_settings_labs">"Labs"</string>
<string name="screen_advanced_settings_live_location_section_description">"Die Entfernung, die du zurücklegen musst, um ein Update auszulösen."</string>
<string name="screen_advanced_settings_live_location_section_title">"Live-Standort-Updates"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="one">"Alle %1$d Meter"</item>
<item quantity="other">"Alle %1$d Meter"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch"</string> <string name="screen_advanced_settings_media_compression_description">"Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch"</string>
<string name="screen_advanced_settings_media_compression_title">"Optimiere die Medienqualität"</string> <string name="screen_advanced_settings_media_compression_title">"Optimiere die Medienqualität"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation und Sicherheit"</string> <string name="screen_advanced_settings_moderation_and_safety_section_title">"Moderation und Sicherheit"</string>
@@ -101,6 +101,14 @@ Ha folytatja, egyes beállítások megváltozhatnak."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"rendszerbeállításokat"</string> <string name="screen_notification_settings_system_notifications_action_required_content_link">"rendszerbeállításokat"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"A rendszerértesítések ki vannak kapcsolva"</string> <string name="screen_notification_settings_system_notifications_turned_off">"A rendszerértesítések ki vannak kapcsolva"</string>
<string name="screen_notification_settings_title">"Értesítések"</string> <string name="screen_notification_settings_title">"Értesítések"</string>
<string name="screen_settings_user_status_away">"Távol"</string>
<string name="screen_settings_user_status_be_right_back">"Rögtön visszajövök"</string>
<string name="screen_settings_user_status_custom">"Egyedi…"</string>
<string name="screen_settings_user_status_custom_hint">"Egyéni állapot…"</string>
<string name="screen_settings_user_status_focus_time">"Feladatra koncentrál"</string>
<string name="screen_settings_user_status_in_a_meeting">"Értekezleten"</string>
<string name="screen_settings_user_status_on_the_road">"Úton"</string>
<string name="screen_settings_user_status_placeholder">"Mi az állapota?"</string>
<string name="theme_black">"Fekete"</string> <string name="theme_black">"Fekete"</string>
<string name="theme_dark">"Sötét"</string> <string name="theme_dark">"Sötét"</string>
<string name="theme_light">"Világos"</string> <string name="theme_light">"Világos"</string>
@@ -33,15 +33,15 @@
<string name="screen_advanced_settings_push_provider_android">"Dostawca powiadomień push"</string> <string name="screen_advanced_settings_push_provider_android">"Dostawca powiadomień push"</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."</string> <string name="screen_advanced_settings_rich_text_editor_description">"Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie."</string>
<string name="screen_advanced_settings_send_read_receipts">"Potwierdzenia odczytania"</string> <string name="screen_advanced_settings_send_read_receipts">"Potwierdzenia odczytania"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane."</string> <string name="screen_advanced_settings_send_read_receipts_description">"Po wyłączeniu potwierdzenia odczytu nie będą wysyłane. Nadal będziesz otrzymywać potwierdzenia od innych użytkowników."</string>
<string name="screen_advanced_settings_share_presence">"Udostępnij obecność"</string> <string name="screen_advanced_settings_share_presence">"Udostępnij obecność"</string>
<string name="screen_advanced_settings_share_presence_description">"Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania."</string> <string name="screen_advanced_settings_share_presence_description">"Po wyłączeniu nie będzie można wysyłać ani odbierać potwierdzeń odczytu i powiadomień o pisaniu."</string>
<string name="screen_advanced_settings_show_media_timeline_always_hide">"Zawsze ukrywaj"</string> <string name="screen_advanced_settings_show_media_timeline_always_hide">"Zawsze ukrywaj"</string>
<string name="screen_advanced_settings_show_media_timeline_always_show">"Zawsze pokazuj"</string> <string name="screen_advanced_settings_show_media_timeline_always_show">"Zawsze pokazuj"</string>
<string name="screen_advanced_settings_show_media_timeline_private_rooms">"W pokojach prywatnych"</string> <string name="screen_advanced_settings_show_media_timeline_private_rooms">"W pokojach prywatnych"</string>
<string name="screen_advanced_settings_show_media_timeline_subtitle">"Ukryte media można zawsze wyświetlić, dotykając ich"</string> <string name="screen_advanced_settings_show_media_timeline_subtitle">"Ukryte media można zawsze wyświetlić, dotykając ich"</string>
<string name="screen_advanced_settings_show_media_timeline_title">"Pokaż media na osi czasu"</string> <string name="screen_advanced_settings_show_media_timeline_title">"Pokaż media na osi czasu"</string>
<string name="screen_advanced_settings_view_source_description">"Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu."</string> <string name="screen_advanced_settings_view_source_description">"Włącz, aby móc wyświetlać źródło wiadomości na osi czasu."</string>
<string name="screen_blocked_users_empty">"Nie blokujesz żadnych użytkowników"</string> <string name="screen_blocked_users_empty">"Nie blokujesz żadnych użytkowników"</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokuj"</string> <string name="screen_blocked_users_unblock_alert_action">"Odblokuj"</string>
<string name="screen_blocked_users_unblock_alert_description">"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."</string> <string name="screen_blocked_users_unblock_alert_description">"Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika."</string>
@@ -59,6 +59,7 @@
<string name="screen_labs_header_title">"Doriți experiențe noi?"</string> <string name="screen_labs_header_title">"Doriți experiențe noi?"</string>
<string name="screen_labs_title">"Laboratoare"</string> <string name="screen_labs_title">"Laboratoare"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Setări adiționale"</string> <string name="screen_notification_settings_additional_settings_section_title">"Setări adiționale"</string>
<string name="screen_notification_settings_call_ringtone_label">"Ton de apel"</string>
<string name="screen_notification_settings_calls_label">"Apeluri audio și video"</string> <string name="screen_notification_settings_calls_label">"Apeluri audio și video"</string>
<string name="screen_notification_settings_configuration_mismatch">"Nepotrivire de configurație"</string> <string name="screen_notification_settings_configuration_mismatch">"Nepotrivire de configurație"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit. <string name="screen_notification_settings_configuration_mismatch_description">"Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit.
@@ -79,14 +80,38 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate."</st
<string name="screen_notification_settings_invite_for_me_label">"Invitații"</string> <string name="screen_notification_settings_invite_for_me_label">"Invitații"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."</string> <string name="screen_notification_settings_mentions_only_disclaimer">"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."</string>
<string name="screen_notification_settings_mentions_section_title">"Mențiuni"</string> <string name="screen_notification_settings_mentions_section_title">"Mențiuni"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"Alegeți un alt sunet…"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"Se utilizează în prezent %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"Sunet de mesaj"</string>
<string name="screen_notification_settings_message_sound_label">"Sunet de mesaj"</string>
<string name="screen_notification_settings_mode_all">"Toate"</string> <string name="screen_notification_settings_mode_all">"Toate"</string>
<string name="screen_notification_settings_mode_mentions">"Mențiuni"</string> <string name="screen_notification_settings_mode_mentions">"Mențiuni"</string>
<string name="screen_notification_settings_notification_section_title">"Anunță-mă pentru"</string> <string name="screen_notification_settings_notification_section_title">"Anunță-mă pentru"</string>
<string name="screen_notification_settings_room_mention_label">"Anunțați-mă pentru @room"</string> <string name="screen_notification_settings_room_mention_label">"Anunțați-mă pentru @room"</string>
<string name="screen_notification_settings_sound_custom_fallback">"Personalizat"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"Sunet personalizat…"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"Eroare la ștergerea fișierului"</string>
<string name="screen_notification_settings_sound_element_default">"Element Implicit"</string>
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"Eroare la importul fișierului"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"A fost intâmpinată o problemă la previzualizarea sunetului de alertă"</string>
<string name="screen_notification_settings_sound_section_title">"Sunet"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"Ignorați eroarea privind sunetul de alertă"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"A fost întâmpinată o problemă la setarea sunetului de alertă"</string>
<string name="screen_notification_settings_sound_silent">"Silențios"</string>
<string name="screen_notification_settings_sound_system_default">"Implicit sistem"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Pentru a primi notificări, vă rugăm să vă schimbați %1$s."</string> <string name="screen_notification_settings_system_notifications_action_required">"Pentru a primi notificări, vă rugăm să vă schimbați %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"Setări de sistem"</string> <string name="screen_notification_settings_system_notifications_action_required_content_link">"Setări de sistem"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Notificările de sistem sunt dezactivate"</string> <string name="screen_notification_settings_system_notifications_turned_off">"Notificările de sistem sunt dezactivate"</string>
<string name="screen_notification_settings_title">"Notificări"</string> <string name="screen_notification_settings_title">"Notificări"</string>
<string name="screen_settings_user_status_away">"Plecat"</string>
<string name="screen_settings_user_status_be_right_back">"Revin imediat"</string>
<string name="screen_settings_user_status_custom">"Personalizat…"</string>
<string name="screen_settings_user_status_custom_hint">"Status personalizat…"</string>
<string name="screen_settings_user_status_focus_time">"Timp de concentrare"</string>
<string name="screen_settings_user_status_in_a_meeting">"Într-o ședință"</string>
<string name="screen_settings_user_status_on_the_road">"Pe drum"</string>
<string name="screen_settings_user_status_placeholder">"Care este statusul dumneavoastră?"</string>
<string name="theme_black">"Negru"</string> <string name="theme_black">"Negru"</string>
<string name="theme_dark">"Întunecat"</string> <string name="theme_dark">"Întunecat"</string>
<string name="theme_light">"Deschis"</string> <string name="theme_light">"Deschis"</string>
@@ -11,6 +11,13 @@
<string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"在聊天室邀請請求中隱藏大頭貼"</string> <string name="screen_advanced_settings_hide_invite_avatars_toggle_title">"在聊天室邀請請求中隱藏大頭貼"</string>
<string name="screen_advanced_settings_hide_timeline_media_toggle_title">"在時間軸中隱藏媒體預覽"</string> <string name="screen_advanced_settings_hide_timeline_media_toggle_title">"在時間軸中隱藏媒體預覽"</string>
<string name="screen_advanced_settings_labs">"實驗室"</string> <string name="screen_advanced_settings_labs">"實驗室"</string>
<string name="screen_advanced_settings_live_location_section_description">"觸發更新所需的移動距離。"</string>
<string name="screen_advanced_settings_live_location_section_footer">"請確保已為此應用程式啟用「精確位置」功能。若要變更此權限,請前往 %1$s。"</string>
<string name="screen_advanced_settings_live_location_section_footer_link">"應用程式設定"</string>
<string name="screen_advanced_settings_live_location_section_title">"即時位置更新"</string>
<plurals name="screen_advanced_settings_live_location_update_distance">
<item quantity="other">"每 %1$d 公尺"</item>
</plurals>
<string name="screen_advanced_settings_media_compression_description">"上傳照片與影片更快且減少資料使用量"</string> <string name="screen_advanced_settings_media_compression_description">"上傳照片與影片更快且減少資料使用量"</string>
<string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</string> <string name="screen_advanced_settings_media_compression_title">"最佳化媒體品質"</string>
<string name="screen_advanced_settings_moderation_and_safety_section_title">"管理與安全"</string> <string name="screen_advanced_settings_moderation_and_safety_section_title">"管理與安全"</string>
@@ -50,6 +57,7 @@
<string name="screen_labs_header_title">"想不想來點新花樣?"</string> <string name="screen_labs_header_title">"想不想來點新花樣?"</string>
<string name="screen_labs_title">"實驗室"</string> <string name="screen_labs_title">"實驗室"</string>
<string name="screen_notification_settings_additional_settings_section_title">"其他設定"</string> <string name="screen_notification_settings_additional_settings_section_title">"其他設定"</string>
<string name="screen_notification_settings_call_ringtone_label">"通話鈴聲"</string>
<string name="screen_notification_settings_calls_label">"音訊與視訊通話"</string> <string name="screen_notification_settings_calls_label">"音訊與視訊通話"</string>
<string name="screen_notification_settings_configuration_mismatch">"組態錯誤"</string> <string name="screen_notification_settings_configuration_mismatch">"組態錯誤"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。 <string name="screen_notification_settings_configuration_mismatch_description">"我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。
@@ -68,14 +76,39 @@
<string name="screen_notification_settings_invite_for_me_label">"邀請"</string> <string name="screen_notification_settings_invite_for_me_label">"邀請"</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。"</string> <string name="screen_notification_settings_mentions_only_disclaimer">"您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。"</string>
<string name="screen_notification_settings_mentions_section_title">"提及"</string> <string name="screen_notification_settings_mentions_section_title">"提及"</string>
<string name="screen_notification_settings_message_sound_dialog_choose_other">"選擇其他音效⋯⋯"</string>
<string name="screen_notification_settings_message_sound_dialog_current_subtitle">"目前正在使用 %1$s"</string>
<string name="screen_notification_settings_message_sound_dialog_title">"訊息音效"</string>
<string name="screen_notification_settings_message_sound_label">"訊息音效"</string>
<string name="screen_notification_settings_mode_all">"全部"</string> <string name="screen_notification_settings_mode_all">"全部"</string>
<string name="screen_notification_settings_mode_mentions">"提及"</string> <string name="screen_notification_settings_mode_mentions">"提及"</string>
<string name="screen_notification_settings_notification_section_title">"通知我"</string> <string name="screen_notification_settings_notification_section_title">"通知我"</string>
<string name="screen_notification_settings_room_mention_label">"於 @room 通知我"</string> <string name="screen_notification_settings_room_mention_label">"於 @room 通知我"</string>
<string name="screen_notification_settings_sound_custom_fallback">"自訂"</string>
<string name="screen_notification_settings_sound_custom_sound_button_title">"自訂音效⋯⋯"</string>
<string name="screen_notification_settings_sound_delete_sound_error_title">"刪除檔案時發生錯誤"</string>
<string name="screen_notification_settings_sound_element_default">"Element 預設值"</string>
<string name="screen_notification_settings_sound_element_fade">"Element Fade"</string>
<string name="screen_notification_settings_sound_import_sound_error_title">"匯入檔案時發生錯誤"</string>
<string name="screen_notification_settings_sound_preview_sound_error_title">"預覽警示音時發生問題"</string>
<string name="screen_notification_settings_sound_section_title">"音效"</string>
<string name="screen_notification_settings_sound_set_sound_error_dismiss_a11y">"忽略警示音錯誤"</string>
<string name="screen_notification_settings_sound_set_sound_error_title">"設定警示音時發生問題"</string>
<string name="screen_notification_settings_sound_silent">"靜音"</string>
<string name="screen_notification_settings_sound_system_default">"系統預設值"</string>
<string name="screen_notification_settings_system_notifications_action_required">"要收到通知,請變更您的 %1$s。"</string> <string name="screen_notification_settings_system_notifications_action_required">"要收到通知,請變更您的 %1$s。"</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"系統設定"</string> <string name="screen_notification_settings_system_notifications_action_required_content_link">"系統設定"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"已關閉系統通知"</string> <string name="screen_notification_settings_system_notifications_turned_off">"已關閉系統通知"</string>
<string name="screen_notification_settings_title">"通知"</string> <string name="screen_notification_settings_title">"通知"</string>
<string name="screen_settings_user_status_away">"離開"</string>
<string name="screen_settings_user_status_be_right_back">"稍後回來"</string>
<string name="screen_settings_user_status_custom">"自訂⋯⋯"</string>
<string name="screen_settings_user_status_custom_hint">"自訂狀態⋯⋯"</string>
<string name="screen_settings_user_status_focus_time">"專注時間"</string>
<string name="screen_settings_user_status_in_a_meeting">"在會議中"</string>
<string name="screen_settings_user_status_on_the_road">"在路上"</string>
<string name="screen_settings_user_status_placeholder">"您的狀態如何?"</string>
<string name="theme_black">"黑色"</string>
<string name="theme_dark">"深色"</string> <string name="theme_dark">"深色"</string>
<string name="theme_light">"淺色"</string> <string name="theme_light">"淺色"</string>
<string name="theme_system">"系統"</string> <string name="theme_system">"系統"</string>
@@ -13,8 +13,10 @@ package io.element.android.features.preferences.impl.blockedusers
import androidx.activity.ComponentActivity 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.longClick
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.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@@ -57,6 +59,20 @@ class BlockedUserViewTest : RobolectricTest() {
eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId))
} }
@Test
fun `long clicking on a user emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
val userList = aMatrixUserList()
setBlockedUsersView(
aBlockedUsersState(
blockedUsers = userList,
eventSink = eventsRecorder
),
)
onNodeWithText(userList.first().displayName.orEmpty()).performTouchInput { longClick() }
eventsRecorder.assertSingle(BlockedUsersEvents.CopyToClipboard(userList.first().userId))
}
@Test @Test
fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest { fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>() val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
@@ -12,7 +12,9 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
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.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -21,6 +23,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_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.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -169,6 +172,29 @@ class BlockedUsersPresenterTest {
} }
} }
@Test
fun `present - copy user id to clipboard`() = runTest {
val clipboardHelper = FakeClipboardHelper()
val matrixClient = FakeMatrixClient(
ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID))
)
val presenter = aBlockedUsersPresenter(
matrixClient = matrixClient,
clipboardHelper = clipboardHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.snackbarMessage).isNull()
initialState.eventSink(BlockedUsersEvents.CopyToClipboard(A_USER_ID))
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_USER_ID.value)
assertThat(awaitItem().snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - confirm unblock without a pending blocked user does nothing`() = runTest { fun `present - confirm unblock without a pending blocked user does nothing`() = runTest {
val presenter = aBlockedUsersPresenter() val presenter = aBlockedUsersPresenter()
@@ -183,8 +209,12 @@ class BlockedUsersPresenterTest {
private fun aBlockedUsersPresenter( private fun aBlockedUsersPresenter(
matrixClient: FakeMatrixClient = FakeMatrixClient(), matrixClient: FakeMatrixClient = FakeMatrixClient(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
) = BlockedUsersPresenter( ) = BlockedUsersPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
clipboardHelper = clipboardHelper,
snackbarDispatcher = snackbarDispatcher,
) )
} }
@@ -8,9 +8,11 @@
package io.element.android.features.preferences.impl.userstatus package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -61,6 +63,24 @@ class UserStatusPresenterTest {
val newState = awaitItem() val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.setUserStatusCalled).isTrue()
}
}
@Test
fun `Set event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
setUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "💬", text = "In a meeting")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
val newState = awaitItem()
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// On failure the SDK does not emit a new profile, so nothing is displayed.
assertThat(newState.displayedStatus).isNull()
assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.setUserStatusCalled).isTrue() assertThat(client.setUserStatusCalled).isTrue()
} }
} }
@@ -121,6 +141,25 @@ class UserStatusPresenterTest {
val clearedState = awaitItem() val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
assertThat(clearedState.displayedStatus).isNull() assertThat(clearedState.displayedStatus).isNull()
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java)
assertThat(client.clearUserStatusCalled).isTrue()
}
}
@Test
fun `Clear event failure surfaces a Failure action`() = runTest {
val client = FakeMatrixClient().apply {
clearUserStatusResult = Result.failure(AN_EXCEPTION)
}
createPresenter(client).test {
val status = UserStatus(emoji = "☕", text = "Be right back")
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
awaitItem().eventSink(UserStatusEvent.ClearStatus)
val clearedState = awaitItem()
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
// Clear failed, so the previously set status is still displayed.
assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(client.clearUserStatusCalled).isTrue() assertThat(client.clearUserStatusCalled).isTrue()
} }
} }
@@ -8,6 +8,8 @@
<string name="screen_bug_report_editor_placeholder">"Descrieți problema…"</string> <string name="screen_bug_report_editor_placeholder">"Descrieți problema…"</string>
<string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string> <string name="screen_bug_report_editor_supporting">"Dacă posibil, vă rugăm să scrieți descrierea în engleză."</string>
<string name="screen_bug_report_error_description_too_short">"Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!"</string> <string name="screen_bug_report_error_description_too_short">"Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!"</string>
<string name="screen_bug_report_github_issue_description">"Puteți introduce numărul unui tichet GitHub asociat, dacă există."</string>
<string name="screen_bug_report_github_issue_label">"Tichet GitHub"</string>
<string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string> <string name="screen_bug_report_include_crash_logs">"Trimiteți log-uri"</string>
<string name="screen_bug_report_include_logs">"Permiteți log-uri"</string> <string name="screen_bug_report_include_logs">"Permiteți log-uri"</string>
<string name="screen_bug_report_include_logs_error">"Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă."</string> <string name="screen_bug_report_include_logs_error">"Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă."</string>

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