mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 20:01:55 +00:00
Merge branch 'release/26.08.1'
This commit is contained in:
@@ -51,12 +51,12 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APKs
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug Gplay Enterprise APK
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
|
||||
@@ -44,13 +44,13 @@ jobs:
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
run: curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
- name: Run Maestro tests in emulator
|
||||
id: maestro_test
|
||||
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
MAESTRO_USERNAME: maestroelement
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
@@ -37,13 +37,13 @@ jobs:
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
@@ -80,12 +80,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
|
||||
@@ -69,12 +69,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- 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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- 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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- 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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- 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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- 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' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Ktlint check
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
|
||||
- uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
upload_reports:
|
||||
name: Project Check Suite
|
||||
|
||||
@@ -54,13 +54,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
# Add gradle cache, this should speed up the process
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- 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
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
@@ -82,12 +82,12 @@ jobs:
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- 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
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
@@ -126,12 +126,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
- name: Create APKs
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
|
||||
@@ -45,12 +45,12 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Build debug code and test fixtures
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
only-labels: "X-Needs-Info"
|
||||
days-before-issue-stale: 30
|
||||
|
||||
@@ -17,12 +17,12 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
|
||||
@@ -12,7 +12,7 @@ permissions: {}
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -63,17 +63,23 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: ⚙️ Check coverage for debug variant (includes unit & screenshot tests)
|
||||
run: ./gradlew testDebugUnitTest :tests:uitests:verifyPaparazziDebug :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: ⚙️ Run unit tests
|
||||
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: ⚙️ Run screenshot tests
|
||||
run: ./gradlew :tests:uitests:verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: ⚙️ Generate kover coverage report
|
||||
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 🚫 Upload kover failed coverage reports
|
||||
if: failure()
|
||||
@@ -81,7 +87,7 @@ jobs:
|
||||
with:
|
||||
name: kover-error-report
|
||||
path: |
|
||||
app/build/reports/kover
|
||||
build/reports/kover
|
||||
|
||||
- name: ✅ Upload kover report (disabled)
|
||||
if: always()
|
||||
|
||||
+83
@@ -1,3 +1,86 @@
|
||||
Changes in Element X v26.08.0
|
||||
=============================
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at v26.08.0 -->
|
||||
|
||||
## What's Changed
|
||||
### ✨ Features
|
||||
* feat(call): Active call timeline rendering by @BillCarsonFr in https://github.com/element-hq/element-x-android/pull/7051
|
||||
### 🙌 Improvements
|
||||
* Announce expanded/collapsed state on the timeline group header by @unclejay80 in https://github.com/element-hq/element-x-android/pull/7226
|
||||
* Allow adding extra elements to the developer settings from external modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7230
|
||||
* Update keyboard visibility to track hasFocus by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7284
|
||||
### 🐛 Bugfixes
|
||||
* Live location sharing timeout by @ganfra in https://github.com/element-hq/element-x-android/pull/7184
|
||||
* Fix logging via QR code when using MDM-configured homeserver by @mxandreas in https://github.com/element-hq/element-x-android/pull/7228
|
||||
* Fix #7181: Remove back arrow from device verified screen and use 'Done' text by @thirumani-vihaan in https://github.com/element-hq/element-x-android/pull/7222
|
||||
* Fix padding for captions of media in 'not found' state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7247
|
||||
* Fix banned mime type errors in `DefaultContentScannerService` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7255
|
||||
* Fix `ContentErrorView` not calculating its text size synchronously by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7281
|
||||
* Fix media viewer for medias not related to events displaying invalid state by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7280
|
||||
* Try making `HeaderFooterView` more robust by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7267
|
||||
* Fix insets in several screens by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7285
|
||||
### ⚠️ API Changes
|
||||
* Add a password strength estimator wrapping the SDK zxcvbn API by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7186
|
||||
### 🗣 Translations
|
||||
* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7277
|
||||
### 🧱 Build
|
||||
* CI: fix 'Update Gradle Wrapper' by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7238
|
||||
* Update Gradle Wrapper from 9.5.1 to 9.6.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7240
|
||||
* Localazy: improve search, parallelize checks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7261
|
||||
* Run `Konsist` tests sequentially by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7293
|
||||
### 📄 Documentation
|
||||
* Group Element Pro changes into a dedicated release notes section by @manuroe in https://github.com/element-hq/element-x-android/pull/7236
|
||||
### 🚧 In development 🚧
|
||||
* Plug emoji picker for custom user status by @ganfra in https://github.com/element-hq/element-x-android/pull/7257
|
||||
* WIP : display user status by @ganfra in https://github.com/element-hq/element-x-android/pull/7232
|
||||
### Dependency upgrades
|
||||
* Update roborazzi to v1.67.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7188
|
||||
* Update dependency io.sentry:sentry-android to v8.48.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7217
|
||||
* Update dependency com.google.crypto.tink:tink-android to v1.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7229
|
||||
* Update kotlin to v2.3.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7234
|
||||
* Update dependency net.zetetic:sqlcipher-android to v4.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7218
|
||||
* Update dependency com.google.firebase:firebase-bom to v34.16.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7237
|
||||
* Update dependencyAnalysis to v3.16.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7212
|
||||
* Update actions/setup-java action to v5.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7214
|
||||
* Update dependency io.element.android:element-call-embedded to v0.22.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7161
|
||||
* Update kotlin to v2.4.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7244
|
||||
* Update roborazzi to v1.68.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7245
|
||||
* Update metro to v1.3.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7243
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.22 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7258
|
||||
* Update zizmorcore/zizmor-action action to v0.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7256
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7262
|
||||
* Update dependency io.sentry:sentry-android to v8.49.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7263
|
||||
* Update dependencyAnalysis to v3.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7265
|
||||
* Update actions/setup-java action to v5.6.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7268
|
||||
* Update dependency org.jetbrains.kotlinx:kover-gradle-plugin to v0.9.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7273
|
||||
* Update actions/setup-python action to v7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7278
|
||||
* Update roborazzi to v1.70.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7274
|
||||
* Update actions/checkout action to v7.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7282
|
||||
* Import Compound tokens from `v10.2.3` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7272
|
||||
* Update dependency org.matrix.rustcomponents:sdk-android to v26.07.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7287
|
||||
* Update dependency org.maplibre.gl:android-sdk to v13.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7290
|
||||
### 💼 Element Pro
|
||||
* Content scanner (Element Pro): scan timeline events for unsafe content by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7159
|
||||
* Add content scanner support to media gallery and viewer (Element Pro) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7233
|
||||
* Enforce HS disabling E2EE in DM creation too (Element Pro) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7292
|
||||
* Allow adding a path suffix to the OAuth client URI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7286
|
||||
### Others
|
||||
* [A11y] Improve rendering on focused UI items by @bmarty in https://github.com/element-hq/element-x-android/pull/7182
|
||||
* Add a button role to clickable icons and text on the login/onboarding screens by @unclejay80 in https://github.com/element-hq/element-x-android/pull/7225
|
||||
* Expose formatting toggle state to TalkBack by @unclejay80 in https://github.com/element-hq/element-x-android/pull/7227
|
||||
* Add warning to the 'Scan QR code' screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7264
|
||||
* Refactor SearchBarResultState.Initial/NoResultsFound to data objects by @ganfra in https://github.com/element-hq/element-x-android/pull/7266
|
||||
* Remove no longer temporary strings by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7260
|
||||
* Use the right insets in the home screen UI by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7271
|
||||
* Make settings section separators visible in black theme by @mvanhorn in https://github.com/element-hq/element-x-android/pull/7039
|
||||
|
||||
## New Contributors
|
||||
* @thirumani-vihaan made their first contribution in https://github.com/element-hq/element-x-android/pull/7222
|
||||
* @mvanhorn made their first contribution in https://github.com/element-hq/element-x-android/pull/7039
|
||||
|
||||
**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.07.1...v26.08.0
|
||||
|
||||
Changes in Element X v26.07.1
|
||||
=============================
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ dependencies {
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.features.login.test)
|
||||
testImplementation(projects.features.share.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oauth.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.log.logger.LoggerTag
|
||||
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.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
@@ -65,6 +67,7 @@ class LoggedInPresenter(
|
||||
private val buildMeta: BuildMeta,
|
||||
private val networkMonitor: NetworkMonitor,
|
||||
private val localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
) : Presenter<LoggedInState> {
|
||||
private val localNetworkPermissionsPresenter: PermissionsPresenter =
|
||||
@@ -120,6 +123,16 @@ class LoggedInPresenter(
|
||||
}.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()
|
||||
LaunchedEffect(networkConnectivity) {
|
||||
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.test.FakeNetworkMonitor
|
||||
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.core.SessionId
|
||||
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.tests.testutils.WarmUpRule
|
||||
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.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -127,6 +131,7 @@ class LoggedInPresenterTest {
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||
).test {
|
||||
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 {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
@@ -362,6 +418,7 @@ class LoggedInPresenterTest {
|
||||
),
|
||||
buildMeta: BuildMeta = aBuildMeta(),
|
||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
): LoggedInPresenter {
|
||||
return LoggedInPresenter(
|
||||
matrixClient = matrixClient,
|
||||
@@ -373,6 +430,7 @@ class LoggedInPresenterTest {
|
||||
buildMeta = buildMeta,
|
||||
networkMonitor = networkMonitor,
|
||||
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
|
||||
featureFlagService = featureFlagService,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,6 +303,8 @@ Follow these steps to install and configure the plugin and templates:
|
||||
- Click on OK
|
||||
4. Configure generate-module-from-template plugin :
|
||||
- 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
|
||||
- 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.
|
||||
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`.
|
||||
To use it, just right click on the package where you want to generate classes, and click on `Template Presentation Classes`.
|
||||
Fill the text field with the base name of the classes, ie `RootRoomDetails` in the `root` package.
|
||||
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: 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
|
||||
|
||||
+1
-1
Submodule enterprise updated: 7bbf7db013...f5f910856c
@@ -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
-1
@@ -39,7 +39,7 @@ fun AnalyticsPreferencesView(
|
||||
)
|
||||
Column(modifier) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(id = R.string.screen_analytics_settings_share_data))
|
||||
},
|
||||
supportingContent = {
|
||||
|
||||
+1
-1
@@ -312,7 +312,7 @@ private fun RoomJoinRuleOptions(
|
||||
backgroundTint = Color.Transparent,
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
content = {
|
||||
val title = when (item) {
|
||||
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
|
||||
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
|
||||
|
||||
+3
-3
@@ -58,7 +58,7 @@ internal fun SelectParentSpaceOptions(
|
||||
modifier = modifier
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = selectedSpace?.displayName
|
||||
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
|
||||
@@ -126,7 +126,7 @@ private fun SelectParentSpaceBottomSheet(
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
|
||||
maxLines = 1
|
||||
@@ -141,7 +141,7 @@ private fun SelectParentSpaceBottomSheet(
|
||||
for (space in spaces) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = space.displayName,
|
||||
maxLines = 1
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
|
||||
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
|
||||
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
|
||||
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
|
||||
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
|
||||
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
|
||||
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</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_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
|
||||
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
|
||||
<string name="screen_deactivate_account_title">"停用帳號"</string>
|
||||
<string name="screen_deactivate_account_title">"刪除帳號"</string>
|
||||
</resources>
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ package io.element.android.features.enterprise.api
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.wellknown.api.ElementWellKnown
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -17,7 +18,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface EnterpriseService {
|
||||
val isEnterpriseBuild: Boolean
|
||||
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
|
||||
suspend fun tweakMasUrl(url: String, homeserver: String): String
|
||||
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
|
||||
fun defaultHomeserverList(): List<String>
|
||||
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
|
||||
|
||||
@@ -47,6 +48,11 @@ interface EnterpriseService {
|
||||
*/
|
||||
fun overriddenElementWellKnown(): ElementWellKnown?
|
||||
|
||||
/**
|
||||
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
|
||||
*/
|
||||
fun essConfigEndpointUrl(domain: String): String?
|
||||
|
||||
companion object {
|
||||
const val ANY_ACCOUNT_PROVIDER = "*"
|
||||
}
|
||||
|
||||
+17
@@ -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
|
||||
+23
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
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.wellknown.api.ElementWellKnown
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
|
||||
override val isEnterpriseBuild = false
|
||||
|
||||
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
|
||||
override fun defaultHomeserverList(): List<String> = emptyList()
|
||||
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
|
||||
|
||||
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
|
||||
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
|
||||
|
||||
override fun overriddenElementWellKnown(): ElementWellKnown? = null
|
||||
|
||||
override fun essConfigEndpointUrl(domain: String): String? = null
|
||||
}
|
||||
|
||||
+30
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -5,10 +5,12 @@
|
||||
* 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.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
|
||||
|
||||
/**
|
||||
@@ -16,5 +18,12 @@ import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
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.wellknown.api.ElementWellKnown
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
|
||||
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
|
||||
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
|
||||
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
|
||||
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
|
||||
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
|
||||
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
|
||||
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
|
||||
) : EnterpriseService {
|
||||
private val brandColorState = MutableStateFlow(initialBrandColor)
|
||||
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
|
||||
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
|
||||
isEnterpriseUserResult(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
|
||||
tweakMasUrlResult(url, homeserver)
|
||||
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
|
||||
tweakMasUrlResult(url, homeserver, urlContentFetcher)
|
||||
}
|
||||
|
||||
override fun defaultHomeserverList(): List<String> {
|
||||
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
|
||||
override fun overriddenElementWellKnown(): ElementWellKnown? {
|
||||
return overrideWellKnownResult()
|
||||
}
|
||||
|
||||
override fun essConfigEndpointUrl(domain: String): String {
|
||||
return essConfigEndpointUrlResult(domain)
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
@@ -14,12 +14,15 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.plus
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -204,6 +207,9 @@ private fun HomeScaffold(
|
||||
floatingActionButton = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
HomeBottomBar(
|
||||
// The Scaffold uses top-only insets so the scrollable content can go edge-to-edge behind the
|
||||
// navigation bar, so the floating toolbar has to apply the bottom inset itself to avoid overlapping it.
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
|
||||
currentHomeNavigationBarItem = state.currentHomeNavigationBarItem,
|
||||
onItemClick = { item ->
|
||||
// scroll to top if selecting the same item
|
||||
|
||||
+127
-5
@@ -8,6 +8,8 @@
|
||||
|
||||
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.Column
|
||||
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.asPaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.pager.VerticalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
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.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.semantics.heading
|
||||
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 io.element.android.appconfig.RoomListConfig
|
||||
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.TopAppBar
|
||||
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.UserStatus
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
import io.element.android.libraries.testtags.testTag
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -151,7 +171,7 @@ fun HomeTopBar(
|
||||
},
|
||||
// We want a 16dp left padding for the navigationIcon :
|
||||
// 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
|
||||
windowInsets = WindowInsets(left = 4.dp),
|
||||
)
|
||||
@@ -316,19 +336,36 @@ private fun AccountIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
|
||||
IconButton(
|
||||
modifier = modifier.then(testTag),
|
||||
onClick = onClick,
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(testTag)
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
indication = ripple(bounded = false),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box {
|
||||
val avatarData by remember(matrixUser) {
|
||||
derivedStateOf {
|
||||
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(
|
||||
avatarData = avatarData,
|
||||
avatarType = AvatarType.User,
|
||||
modifier = avatarModifier,
|
||||
contentDescription = if (isCurrentAccount) {
|
||||
if (showAvatarIndicator) {
|
||||
stringResource(CommonStrings.a11y_settings_with_required_action)
|
||||
@@ -339,11 +376,69 @@ private fun AccountIcon(
|
||||
null
|
||||
},
|
||||
)
|
||||
if (statusEmoji != null) {
|
||||
StatusEmojiBadge(
|
||||
emoji = statusEmoji,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
if (showAvatarIndicator) {
|
||||
RedIndicatorAtom(
|
||||
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)
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
|
||||
override val filterSelectionStates = MutableStateFlow(buildFilters())
|
||||
|
||||
override fun select(filter: RoomListFilter) {
|
||||
if (selectedFilters.any { it in filter.incompatibleFilters }) return
|
||||
selectedFilters.add(filter)
|
||||
filterSelectionStates.value = buildFilters()
|
||||
}
|
||||
|
||||
+7
-7
@@ -94,7 +94,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
@@ -104,7 +104,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
)
|
||||
if (contextMenu.hasNewContent) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.action_mark_as_read),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -117,7 +117,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -135,7 +135,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
CommonStrings.common_favourite to CompoundIcons.Favourite()
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = stringResource(id = textResId),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -154,7 +154,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
},
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = stringResource(id = CommonStrings.common_settings),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@@ -169,7 +169,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
)
|
||||
if (canReportRoom) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(CommonStrings.action_report_room))
|
||||
},
|
||||
modifier = Modifier.clickable { onReportRoomClick() },
|
||||
@@ -182,7 +182,7 @@ private fun RoomListModalBottomSheetContent(
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(CommonStrings.action_leave_room))
|
||||
},
|
||||
modifier = Modifier.clickable { onLeaveRoomClick() },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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_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_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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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_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_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>
|
||||
|
||||
+43
@@ -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
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `present - clear filters event`() = runTest {
|
||||
|
||||
+2
-2
@@ -81,7 +81,7 @@ fun DeclineAndBlockView(
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title))
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -96,7 +96,7 @@ fun DeclineAndBlockView(
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
ListItem(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(CommonStrings.action_report_room))
|
||||
},
|
||||
supportingContent = {
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<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_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>
|
||||
|
||||
@@ -2,4 +2,8 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invite_users_already_a_member">"已是成員"</string>
|
||||
<string name="screen_invite_users_already_invited">"已邀請"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
|
||||
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
|
||||
</resources>
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ fun DependenciesDetailsView(
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
ClickableLinkText(
|
||||
text = text,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ fun DependencyLicensesListView(
|
||||
}
|
||||
is AsyncData.Success -> items(state.licenses.data) { license ->
|
||||
ListItem(
|
||||
headlineContent = { Text(license.safeName) },
|
||||
content = { Text(license.safeName) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
buildString {
|
||||
|
||||
+17
-5
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
|
||||
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
|
||||
import io.element.android.libraries.matrix.api.logs.LoggerTags
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
|
||||
|
||||
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
|
||||
get() = linkDesktopStepFlow.asStateFlow()
|
||||
|
||||
private var currentJob: Job? = null
|
||||
private var timerJob: Job? = null
|
||||
private var handler: LinkDesktopHandler? = null
|
||||
|
||||
fun createNewHandler() {
|
||||
private fun resetJobs() {
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
}
|
||||
|
||||
fun createNewHandler() {
|
||||
resetJobs()
|
||||
handler = matrixClient.createLinkDesktopHandler().getOrNull()
|
||||
timerJob = sessionScope.launch {
|
||||
delay(2.minutes)
|
||||
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
resetJobs()
|
||||
sessionScope.launch {
|
||||
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
fun onScannedCode(data: ByteArray) {
|
||||
currentJob?.cancel()
|
||||
currentJob = null
|
||||
resetJobs()
|
||||
val currentHandler = handler
|
||||
if (currentHandler == null) {
|
||||
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
|
||||
|
||||
+14
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
|
||||
},
|
||||
onResume = ::onResume,
|
||||
onDestroy = {
|
||||
linkMobileHandlerJob?.cancel()
|
||||
linkDesktopHandlerJob?.cancel()
|
||||
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
|
||||
private fun onResume() = sessionCoroutineScope.launch {
|
||||
// Application is resumed, if the step is waiting for auth, send the confirmation
|
||||
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
|
||||
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
|
||||
it.continuationMessageSender.confirm()
|
||||
}
|
||||
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
|
||||
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
|
||||
it.continuationMessageSender.confirm()
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToError(errorType: ErrorType) {
|
||||
// Map the error to an error screen
|
||||
val error = when (errorType) {
|
||||
|
||||
+8
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
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)
|
||||
@AssistedInject
|
||||
class ShowQrCodeNode(
|
||||
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
|
||||
private val callback: Callback = callback()
|
||||
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
|
||||
initialData = inputs.data,
|
||||
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
+8
-7
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
|
||||
|
||||
@AssistedInject
|
||||
class ShowQrCodePresenter(
|
||||
@Assisted private val initialData: String,
|
||||
@Assisted private val maxQrCodeRotation: Int,
|
||||
private val linkNewMobileHandler: LinkNewMobileHandler,
|
||||
) : Presenter<ShowQrCodeState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(initialData: String): ShowQrCodePresenter
|
||||
fun create(
|
||||
initialData: String,
|
||||
maxQrCodeRotation: Int,
|
||||
): ShowQrCodePresenter
|
||||
}
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
|
||||
@Composable
|
||||
override fun present(): ShowQrCodeState {
|
||||
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
|
||||
var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
|
||||
val state by produceState(
|
||||
initialValue = ShowQrCodeState(
|
||||
data = AsyncData.Success(initialData),
|
||||
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
|
||||
linkNewMobileHandler.rotateQrCode()
|
||||
// Ensure that outdated data is not rendered too long while rotating QR code
|
||||
loadingJob = launch {
|
||||
delay(1000)
|
||||
delay(1.seconds)
|
||||
value = ShowQrCodeState(
|
||||
data = AsyncData.Loading(),
|
||||
)
|
||||
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
|
||||
|
||||
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_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_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_notice">"Sisesta kahekohaline kood"</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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -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_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_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_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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<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_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_notice">"輸入兩位數代碼"</string>
|
||||
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
|
||||
|
||||
+26
@@ -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(
|
||||
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
|
||||
maxQrCodeRotation: Int = 10,
|
||||
) = ShowQrCodePresenter(
|
||||
initialData = "DATA",
|
||||
maxQrCodeRotation = maxQrCodeRotation,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-8
@@ -29,14 +29,11 @@ internal class MapTilerTileServerStyleUriBuilder(
|
||||
customMapStyleUrl: String?,
|
||||
darkMode: Boolean,
|
||||
): String {
|
||||
return buildString {
|
||||
if (customMapStyleUrl.isNullOrBlank()) {
|
||||
val mapId = if (darkMode) darkMapId else lightMapId
|
||||
append("$baseUrl/$mapId/style.json")
|
||||
} else {
|
||||
append(customMapStyleUrl)
|
||||
}
|
||||
append("?key=$apiKey")
|
||||
return if (customMapStyleUrl.isNullOrBlank()) {
|
||||
val mapId = if (darkMode) darkMapId else lightMapId
|
||||
"$baseUrl/$mapId/style.json?key=$apiKey"
|
||||
} else {
|
||||
customMapStyleUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -46,7 +46,7 @@ class MapTilerTileServerStyleUriBuilderTest {
|
||||
customMapStyleUrl = "https://custom.url/style.json",
|
||||
darkMode = false,
|
||||
)
|
||||
).isEqualTo("https://custom.url/style.json?key=anApiKey")
|
||||
).isEqualTo("https://custom.url/style.json")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -56,6 +56,36 @@ class MapTilerTileServerStyleUriBuilderTest {
|
||||
customMapStyleUrl = "https://custom.url/style.json",
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -261,7 +261,7 @@ private fun ShareCurrentLocationItem(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(CommonStrings.screen_share_my_location_action))
|
||||
},
|
||||
onClick = onClick,
|
||||
@@ -276,7 +276,7 @@ private fun SharePinLocationItem(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(CommonStrings.screen_share_this_location_action))
|
||||
},
|
||||
onClick = onClick,
|
||||
@@ -291,7 +291,7 @@ private fun ShareLiveLocationItem(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(CommonStrings.action_share_live_location))
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<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_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
|
||||
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
|
||||
</resources>
|
||||
|
||||
+5
-2
@@ -59,7 +59,7 @@ class DefaultBiometricAuthentication(
|
||||
private var cryptoObject: CryptoObject? = null
|
||||
|
||||
override suspend fun setup() {
|
||||
if (cryptoObject != null) return
|
||||
Timber.d("Setup biometric authentication")
|
||||
try {
|
||||
val secretKey = ensureKey()
|
||||
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
|
||||
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
|
||||
if (this == null) return false
|
||||
return runCatchingExceptions {
|
||||
doFinal("biometric_challenge".toByteArray())
|
||||
}.isSuccess
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to validate cipher")
|
||||
}
|
||||
.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ fun LockScreenSettingsView(
|
||||
) {
|
||||
PreferenceCategory(showTopDivider = false) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(id = R.string.screen_app_lock_settings_change_pin))
|
||||
},
|
||||
onClick = onChangePinClick,
|
||||
@@ -46,7 +46,7 @@ fun LockScreenSettingsView(
|
||||
PreferenceDivider()
|
||||
if (state.showRemovePinOption) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(id = R.string.screen_app_lock_settings_remove_pin))
|
||||
},
|
||||
style = ListItemStyle.Destructive,
|
||||
|
||||
+19
-10
@@ -13,13 +13,15 @@ import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
|
||||
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
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultAccountProviderAccessControl(
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val wellknownRetriever: WellknownRetriever,
|
||||
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
|
||||
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
|
||||
) : AccountProviderAccessControl {
|
||||
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
|
||||
assertIsAllowedToConnectToAccountProvider(
|
||||
@@ -38,14 +40,21 @@ class DefaultAccountProviderAccessControl(
|
||||
) {
|
||||
if (enterpriseService.isEnterpriseBuild.not()) {
|
||||
// Ensure that Element Pro is not required for this account provider
|
||||
val wellKnown = wellknownRetriever.getElementWellKnown(
|
||||
baseUrl = accountProviderUrl.ensureProtocol(),
|
||||
).dataOrNull()
|
||||
if (wellKnown?.enforceElementPro == true) {
|
||||
throw AccountProviderAccessException.NeedElementProException(
|
||||
unauthorisedAccountProviderTitle = title,
|
||||
applicationId = ELEMENT_PRO_APPLICATION_ID,
|
||||
)
|
||||
val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
|
||||
temporaryMatrixClient.use {
|
||||
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
|
||||
val wellKnown = wellknownRetriever.getElementWellKnown(
|
||||
host = accountProviderUrl,
|
||||
// It's expected this is hardcoded to the well-known endpoint
|
||||
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
|
||||
).dataOrNull()
|
||||
|
||||
if (wellKnown?.enforceElementPro == true) {
|
||||
throw AccountProviderAccessException.NeedElementProException(
|
||||
unauthorisedAccountProviderTitle = title,
|
||||
applicationId = ELEMENT_PRO_APPLICATION_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
|
||||
|
||||
+9
-2
@@ -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.isValidUrl
|
||||
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 kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -34,8 +36,13 @@ class HomeserverResolver(
|
||||
fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
|
||||
val flowContext = currentCoroutineContext()
|
||||
val trimmedUserInput = userInput.trim()
|
||||
if (trimmedUserInput.length < 4) return@flow
|
||||
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
|
||||
val searchTerm = when {
|
||||
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 currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
|
||||
// Run all the requests in parallel
|
||||
|
||||
+22
-12
@@ -13,6 +13,8 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
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 timber.log.Timber
|
||||
|
||||
@@ -22,24 +24,32 @@ interface WebClientUrlForAuthenticationRetriever {
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultWebClientUrlForAuthenticationRetriever(
|
||||
private val wellknownRetriever: WellknownRetriever,
|
||||
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
|
||||
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
|
||||
) : WebClientUrlForAuthenticationRetriever {
|
||||
override suspend fun retrieve(homeServerUrl: String): String {
|
||||
if (homeServerUrl != AuthenticationConfig.MATRIX_ORG_URL) {
|
||||
Timber.w("Temporary account creation flow is only supported on matrix.org")
|
||||
throw AccountCreationNotSupported()
|
||||
}
|
||||
val wellknown = wellknownRetriever.getElementWellKnown(homeServerUrl).dataOrNull()
|
||||
?: throw AccountCreationNotSupported()
|
||||
val registrationHelperUrl = wellknown.registrationHelperUrl
|
||||
return if (registrationHelperUrl != null) {
|
||||
registrationHelperUrl.toUri()
|
||||
.buildUpon()
|
||||
.appendQueryParameter("hs_url", homeServerUrl)
|
||||
.build()
|
||||
.toString()
|
||||
} else {
|
||||
throw AccountCreationNotSupported()
|
||||
val temporaryMatrixClient = temporaryMatrixClientFactory.create(homeServerUrl).getOrThrow()
|
||||
return temporaryMatrixClient.use {
|
||||
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
|
||||
val wellknown = wellknownRetriever.getElementWellKnown(
|
||||
host = homeServerUrl,
|
||||
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT
|
||||
).dataOrNull() ?: throw AccountCreationNotSupported()
|
||||
|
||||
val registrationHelperUrl = wellknown.registrationHelperUrl
|
||||
if (registrationHelperUrl != null) {
|
||||
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_title">"Willkommen zurück!"</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 > Sicherheit & 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_checking_account">"Konto wird geprüft"</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_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_title">"Välj din server"</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_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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<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_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_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>
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="screen_change_server_subtitle">"您的伺服器地址?"</string>
|
||||
<string name="screen_change_server_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_user_id">"此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’"</string>
|
||||
<string name="screen_login_error_refresh_tokens">"此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。"</string>
|
||||
|
||||
+13
-8
@@ -12,10 +12,12 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
|
||||
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.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_URL
|
||||
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -156,15 +158,18 @@ class DefaultAccountProviderAccessControlTest {
|
||||
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
|
||||
defaultHomeserverListResult = { allowedAccountProviders },
|
||||
),
|
||||
wellknownRetriever = FakeWellknownRetriever(
|
||||
getElementWellKnownResult = {
|
||||
if (elementWellKnown == null) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Success(elementWellKnown)
|
||||
}
|
||||
},
|
||||
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(
|
||||
wellknownRetriever = FakeWellknownRetriever(
|
||||
getElementWellKnownResult = { _, _ ->
|
||||
if (elementWellKnown == null) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Success(elementWellKnown)
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
|
||||
)
|
||||
|
||||
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
|
||||
|
||||
+8
-9
@@ -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.localnetwork.LocalNetworkPermissionGate
|
||||
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.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.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.aMatrixHomeServerDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
|
||||
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
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.WellknownRetrieverResult
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
@@ -158,7 +159,8 @@ class ChangeServerPresenterTest {
|
||||
|
||||
@Test
|
||||
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(
|
||||
anElementWellKnown(
|
||||
enforceElementPro = true,
|
||||
@@ -174,8 +176,6 @@ class ChangeServerPresenterTest {
|
||||
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
|
||||
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
|
||||
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.changeServerAction).isInstanceOf(AsyncData.Loading::class.java)
|
||||
val failureState = awaitItem()
|
||||
assertThat(
|
||||
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
|
||||
@@ -183,9 +183,7 @@ class ChangeServerPresenterTest {
|
||||
assertThat(
|
||||
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
|
||||
).isEqualTo("io.element.enterprise")
|
||||
getElementWellKnownResult.assertions()
|
||||
.isCalledOnce()
|
||||
.with(value(A_HOMESERVER_URL.ensureProtocol()))
|
||||
getElementWellKnownResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +267,8 @@ class ChangeServerPresenterTest {
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||
enterpriseService = enterpriseService,
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
|
||||
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
|
||||
),
|
||||
localNetworkPermissionGate = LocalNetworkPermissionGate(
|
||||
advisor = localNetworkPermissionAdvisor,
|
||||
|
||||
+108
@@ -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(),
|
||||
)
|
||||
}
|
||||
+10
-4
@@ -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.WebClientUrlForAuthenticationRetriever
|
||||
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.core.meta.BuildMeta
|
||||
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_2
|
||||
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.core.aBuildMeta
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
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.test.FakeLocalNetworkPermissionAdvisor
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
@@ -304,7 +309,8 @@ private fun createPresenter(
|
||||
enterpriseService = enterpriseService,
|
||||
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
|
||||
enterpriseService = enterpriseService,
|
||||
wellknownRetriever = wellknownRetriever,
|
||||
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
|
||||
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
|
||||
),
|
||||
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
|
||||
loginModePresenter = loginModePresenter,
|
||||
@@ -318,9 +324,9 @@ fun createLoginModePresenter(
|
||||
authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
|
||||
webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(),
|
||||
localNetworkPermissionAdvisor: LocalNetworkPermissionAdvisor =
|
||||
io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory: io.element.android.libraries.permissions.api.PermissionsPresenter.Factory =
|
||||
io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory(),
|
||||
FakeLocalNetworkPermissionAdvisor(),
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory =
|
||||
FakePermissionsPresenterFactory(),
|
||||
): LoginModePresenter = LoginModePresenter(
|
||||
oAuthActionFlow = oAuthActionFlow,
|
||||
authenticationService = authenticationService,
|
||||
|
||||
+2
-2
@@ -248,7 +248,7 @@ private fun ActionListViewContent(
|
||||
modifier = Modifier.clickable {
|
||||
onActionClick(action)
|
||||
},
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = action.titleRes))
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Resource(action.icon)),
|
||||
@@ -461,7 +461,7 @@ private fun VerifiedUserSendFailureView(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ErrorSolid())),
|
||||
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChevronRight())),
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = sendFailure.headline(),
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
|
||||
+1
-1
@@ -494,7 +494,7 @@ private fun VideoQualitySelectorDialog(
|
||||
val estimationMb = sizeFormatter.format(videoEstimation.sizeInBytes, true)
|
||||
val title = "${preset.title()} ($estimationMb)"
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
|
||||
+7
-7
@@ -106,22 +106,22 @@ private fun AttachmentSourcePickerMenu(
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.PhotoFromCamera) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_photo)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.VideoFromCamera) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VideoCall())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) },
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.PickAttachmentSource.FromFiles) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_source_files)) },
|
||||
)
|
||||
if (state.canShareLocation) {
|
||||
ListItem(
|
||||
@@ -130,7 +130,7 @@ private fun AttachmentSourcePickerMenu(
|
||||
onSendLocationClick()
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
@@ -139,13 +139,13 @@ private fun AttachmentSourcePickerMenu(
|
||||
onCreatePollClick()
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
|
||||
)
|
||||
if (enableTextFormatting) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { state.eventSink(MessageComposerEvent.ToggleTextFormatting(enabled = true)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TextFormatting())),
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
|
||||
content = { Text(stringResource(R.string.screen_room_attachment_text_formatting)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -28,6 +28,7 @@ import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.MessagesEvent
|
||||
import io.element.android.features.messages.impl.MessagesNavigator
|
||||
import io.element.android.features.messages.impl.MessagesPresenter
|
||||
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.TimelineEvent
|
||||
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.TimelineItemPresenterFactories
|
||||
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.designsystem.utils.OnLifecycleEvent
|
||||
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.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -91,6 +94,7 @@ class ThreadedMessagesNode(
|
||||
private val permalinkParser: PermalinkParser,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
) : Node(buildContext, plugins = plugins), MessagesNavigator {
|
||||
data class Inputs(
|
||||
val threadRootEventId: ThreadId,
|
||||
@@ -322,7 +326,15 @@ class ThreadedMessagesNode(
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
customReactionBottomSheet = {
|
||||
CustomReactionBottomSheet(
|
||||
state = state.customReactionState,
|
||||
onSelectEmoji = { uniqueId, emoji ->
|
||||
state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId))
|
||||
},
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
)
|
||||
},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
|
||||
|
||||
+7
-10
@@ -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.getDisplayName
|
||||
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.ui.media.contentvalidation.collectOverallState
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState
|
||||
@@ -207,11 +208,7 @@ fun TimelineItemEventRow(
|
||||
}
|
||||
|
||||
fun onUserDataClick() {
|
||||
val sender = MatrixUser(
|
||||
userId = event.senderId,
|
||||
displayName = event.senderProfile.getDisplayName(),
|
||||
avatarUrl = event.senderProfile.getAvatarUrl(),
|
||||
)
|
||||
val sender = event.senderProfile.toMatrixUser(event.senderId)
|
||||
onUserDataClick(sender)
|
||||
}
|
||||
|
||||
@@ -804,15 +801,15 @@ private fun MessageEventBubbleContent(
|
||||
.padding(top = topPadding, start = 8.dp, end = 8.dp)
|
||||
.clip(shape)
|
||||
|
||||
val talkbackCompatModifier = if (isTalkbackActive()) {
|
||||
val talkbackCompatModifier = when {
|
||||
// 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
|
||||
inReplyToModifier.zIndex(1f)
|
||||
} else {
|
||||
inReplyToModifier.clickable(onClick = inReplyToClick)
|
||||
isTalkbackActive() -> inReplyToModifier.zIndex(1f)
|
||||
inReplyTo is InReplyToDetails.Error -> inReplyToModifier
|
||||
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 backgroundColor = if (contentHasError) ElementTheme.colors.bgCriticalSubtle else ElementTheme.colors.bgCanvasDefault
|
||||
Box(
|
||||
|
||||
+17
@@ -8,11 +8,16 @@
|
||||
|
||||
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.isImeVisible
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
@@ -54,6 +59,7 @@ fun CustomReactionBottomSheet(
|
||||
modifier = modifier,
|
||||
scrollable = false,
|
||||
) {
|
||||
ExpandSheetWhenImeIsVisible(sheetState)
|
||||
emojiPickerRenderer.Render(
|
||||
state = state.target.emojiPickerState,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ private fun ReadReceiptBottomSheetContent(
|
||||
LazyColumn {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = CommonStrings.common_seen_by))
|
||||
}
|
||||
)
|
||||
|
||||
+3
-6
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateSetOf
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.zacsweers.metro.Inject
|
||||
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.core.coroutine.mapState
|
||||
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.isPreviewEnabled
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
@@ -32,9 +30,8 @@ class TimelineProtectionPresenter(
|
||||
private val mediaPreviewService: MediaPreviewService,
|
||||
private val room: BaseRoom,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val timelineProtectionStore: TimelineProtectionStore,
|
||||
) : Presenter<TimelineProtectionState> {
|
||||
private val allowedEvents = mutableStateSetOf<EventId>()
|
||||
|
||||
@Composable
|
||||
override fun present(): TimelineProtectionState {
|
||||
val mediaPreviewValue = remember {
|
||||
@@ -48,7 +45,7 @@ class TimelineProtectionPresenter(
|
||||
if (isPreviewEnabled) {
|
||||
ProtectionState.RenderAll
|
||||
} else {
|
||||
ProtectionState.RenderOnly(eventIds = allowedEvents.toImmutableSet())
|
||||
ProtectionState.RenderOnly(eventIds = timelineProtectionStore.allowedEventIds.toImmutableSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +53,7 @@ class TimelineProtectionPresenter(
|
||||
fun handleEvent(event: TimelineProtectionEvent) {
|
||||
when (event) {
|
||||
is TimelineProtectionEvent.ShowContent -> {
|
||||
allowedEvents += setOfNotNull(event.eventId)
|
||||
event.eventId?.let(timelineProtectionStore::allowEvent)
|
||||
}
|
||||
is TimelineProtectionEvent.ValidateContent -> {
|
||||
contentScannerService.scan(event.mediaSources, event.validationState)
|
||||
|
||||
+32
@@ -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_reactions_summary">"Reakció-összefoglaló megjelenítése"</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">
|
||||
<item quantity="one">"%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_title">"Dodaj media"</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_hint">"Powód zgłoszenia treści"</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_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="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_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>
|
||||
@@ -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_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_title">"Adăugați media"</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_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_reactions_summary">"Afișați rezumatul reacțiilor"</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">
|
||||
<item quantity="one">"%1$d schimbare a camerii"</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_unsigned_device">"由未經其擁有者驗證的裝置加密。"</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_change_video_quality_prompt">"輕點即可變更影片上傳品質"</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_optimize_image_quality_title">"最佳化影像品質"</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_hint">"檢查您是否要隱藏所有來自此使用者的目前及未來的訊息"</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_reactions_summary">"顯示反應摘要"</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">
|
||||
<item quantity="other">"%1$d 個聊天室變更"</item>
|
||||
</plurals>
|
||||
|
||||
+65
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
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.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToIndex
|
||||
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.UniqueId
|
||||
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.TextMessageType
|
||||
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.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
|
||||
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
|
||||
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(
|
||||
"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."
|
||||
|
||||
+62
@@ -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
|
||||
}
|
||||
+20
@@ -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)
|
||||
@Test
|
||||
fun `present - validate media scans the media source`() = runTest {
|
||||
@@ -134,9 +152,11 @@ class TimelineProtectionPresenterTest {
|
||||
room: BaseRoom = FakeBaseRoom(),
|
||||
mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||
contentScannerService: ContentScannerService = ContentScannerService { _: List<MediaSource>, _: ContentValidationState -> },
|
||||
timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(),
|
||||
) = TimelineProtectionPresenter(
|
||||
mediaPreviewService = mediaPreviewService,
|
||||
room = room,
|
||||
contentScannerService = contentScannerService,
|
||||
timelineProtectionStore = timelineProtectionStore,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-5
@@ -107,7 +107,7 @@ fun CreatePollView(
|
||||
item {
|
||||
Column {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
TextField(
|
||||
label = stringResource(id = R.string.screen_create_poll_question_desc),
|
||||
value = state.question,
|
||||
@@ -127,7 +127,7 @@ fun CreatePollView(
|
||||
itemsIndexed(state.answers) { index, answer ->
|
||||
val isLastItem = index == state.answers.size - 1
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
TextField(
|
||||
value = answer.text,
|
||||
onValueChange = {
|
||||
@@ -155,7 +155,7 @@ fun CreatePollView(
|
||||
if (state.canAddAnswer) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
|
||||
content = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
|
||||
leadingContent = ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(CompoundIcons.Plus()),
|
||||
),
|
||||
@@ -174,7 +174,7 @@ fun CreatePollView(
|
||||
Column {
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
|
||||
content = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
|
||||
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.pollKind == PollKind.Undisclosed,
|
||||
@@ -189,7 +189,7 @@ fun CreatePollView(
|
||||
)
|
||||
if (state.canDelete) {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
|
||||
content = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
|
||||
style = ListItemStyle.Destructive,
|
||||
onClick = { state.eventSink(CreatePollEvent.Delete(confirmed = false)) },
|
||||
)
|
||||
|
||||
+2
-2
@@ -34,14 +34,14 @@ fun AboutView(
|
||||
) {
|
||||
state.elementLegals.forEach { elementLegal ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(id = elementLegal.titleRes))
|
||||
},
|
||||
onClick = { onElementLegalClick(elementLegal) }
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(stringResource(id = CommonStrings.common_open_source_licenses))
|
||||
},
|
||||
onClick = onOpenSourceLicensesClick,
|
||||
|
||||
+9
-9
@@ -93,7 +93,7 @@ fun AdvancedSettingsView(
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = CommonStrings.action_view_source))
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -105,7 +105,7 @@ fun AdvancedSettingsView(
|
||||
onClick = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(!state.isDeveloperModeEnabled)) }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_share_presence))
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -122,7 +122,7 @@ fun AdvancedSettingsView(
|
||||
null -> Unit
|
||||
is MediaOptimizationState.AllMedia -> {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_media_compression_title))
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -146,7 +146,7 @@ fun AdvancedSettingsView(
|
||||
}
|
||||
is MediaOptimizationState.Split -> {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_image_upload_quality_title))
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -171,7 +171,7 @@ fun AdvancedSettingsView(
|
||||
var displaySelectorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(text = stringResource(id = R.string.screen_advanced_settings_optimise_video_upload_quality_title))
|
||||
},
|
||||
supportingContent = {
|
||||
@@ -247,7 +247,7 @@ private fun VideoQualitySelectorDialog(
|
||||
VideoCompressionPreset.HIGH -> stringResource(CommonStrings.common_video_quality_high_description)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
content = {
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
@@ -301,7 +301,7 @@ private fun ModerationAndSafety(
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
|
||||
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_hide)) },
|
||||
leadingContent = ListItemContent.RadioButton(
|
||||
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Off,
|
||||
compact = true
|
||||
@@ -312,7 +312,7 @@ private fun ModerationAndSafety(
|
||||
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
|
||||
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_private_rooms)) },
|
||||
leadingContent = ListItemContent.RadioButton(
|
||||
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.Private,
|
||||
compact = true
|
||||
@@ -323,7 +323,7 @@ private fun ModerationAndSafety(
|
||||
enabled = !state.mediaPreviewConfigState.setTimelineMediaPreviewAction.isLoading()
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
|
||||
content = { Text(text = stringResource(R.string.screen_advanced_settings_show_media_timeline_always_show)) },
|
||||
leadingContent = ListItemContent.RadioButton(
|
||||
selected = state.mediaPreviewConfigState.timelineMediaPreviewValue == MediaPreviewValue.On,
|
||||
compact = true
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
sealed interface BlockedUsersEvents {
|
||||
data class Unblock(val userId: UserId) : BlockedUsersEvents
|
||||
data class CopyToClipboard(val userId: UserId) : BlockedUsersEvents
|
||||
data object ConfirmUnblock : BlockedUsersEvents
|
||||
data object Cancel : BlockedUsersEvents
|
||||
}
|
||||
|
||||
+14
@@ -18,14 +18,19 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.Presenter
|
||||
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.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -34,6 +39,8 @@ import kotlinx.coroutines.launch
|
||||
class BlockedUsersPresenter(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val clipboardHelper: ClipboardHelper,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<BlockedUsersState> {
|
||||
@Composable
|
||||
override fun present(): BlockedUsersState {
|
||||
@@ -65,12 +72,18 @@ class BlockedUsersPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
fun handleEvent(event: BlockedUsersEvents) {
|
||||
when (event) {
|
||||
is BlockedUsersEvents.Unblock -> {
|
||||
pendingUserToUnblock = event.userId
|
||||
unblockUserAction.value = AsyncAction.ConfirmingNoParams
|
||||
}
|
||||
is BlockedUsersEvents.CopyToClipboard -> {
|
||||
clipboardHelper.copyPlainText(event.userId.value)
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
|
||||
}
|
||||
BlockedUsersEvents.ConfirmUnblock -> {
|
||||
pendingUserToUnblock?.let {
|
||||
coroutineScope.unblockUser(it, unblockUserAction)
|
||||
@@ -86,6 +99,7 @@ class BlockedUsersPresenter(
|
||||
return BlockedUsersState(
|
||||
blockedUsers = ignoredMatrixUser.toImmutableList(),
|
||||
unblockUserAction = unblockUserAction.value,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -9,11 +9,13 @@
|
||||
package io.element.android.features.preferences.impl.blockedusers
|
||||
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class BlockedUsersState(
|
||||
val blockedUsers: ImmutableList<MatrixUser>,
|
||||
val unblockUserAction: AsyncAction<Unit>,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val eventSink: (BlockedUsersEvents) -> Unit,
|
||||
)
|
||||
|
||||
+3
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.blockedusers
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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.ui.components.aMatrixUserList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -30,11 +31,13 @@ class BlockedUsersStateProvider : PreviewParameterProvider<BlockedUsersState> {
|
||||
internal fun aBlockedUsersState(
|
||||
blockedUsers: List<MatrixUser> = aMatrixUserList(),
|
||||
unblockUserAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
eventSink: (BlockedUsersEvents) -> Unit = {},
|
||||
): BlockedUsersState {
|
||||
return BlockedUsersState(
|
||||
blockedUsers = blockedUsers.toImmutableList(),
|
||||
unblockUserAction = unblockUserAction,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
}
|
||||
|
||||
+14
-4
@@ -8,7 +8,7 @@
|
||||
|
||||
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.padding
|
||||
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.theme.components.Scaffold
|
||||
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.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
@@ -43,6 +45,7 @@ fun BlockedUsersView(
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
Box(modifier = modifier) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -52,7 +55,8 @@ fun BlockedUsersView(
|
||||
BackButton(onClick = onBackClick)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding)
|
||||
@@ -60,7 +64,8 @@ fun BlockedUsersView(
|
||||
items(state.blockedUsers) { matrixUser ->
|
||||
BlockedUserItem(
|
||||
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(
|
||||
matrixUser: MatrixUser,
|
||||
onClick: (UserId) -> Unit,
|
||||
onLongClick: (UserId) -> Unit,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
+3
@@ -31,6 +31,7 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.data.ByteUnit
|
||||
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
@@ -41,6 +42,7 @@ import kotlinx.coroutines.launch
|
||||
class DeveloperSettingsPresenter(
|
||||
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
|
||||
private val sessionId: SessionId,
|
||||
private val deviceId: DeviceId,
|
||||
private val computeCacheSizeUseCase: ComputeCacheSizeUseCase,
|
||||
private val clearCacheUseCase: ClearCacheUseCase,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
@@ -117,6 +119,7 @@ class DeveloperSettingsPresenter(
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
|
||||
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
|
||||
showColorPicker = showColorPicker,
|
||||
deviceId = deviceId,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ package io.element.android.features.preferences.impl.developer
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
data class DeveloperSettingsState(
|
||||
@@ -21,6 +22,7 @@ data class DeveloperSettingsState(
|
||||
val markAllRoomsAsReadAction: AsyncAction<Unit>,
|
||||
val isEnterpriseBuild: Boolean,
|
||||
val showColorPicker: Boolean,
|
||||
val deviceId: DeviceId,
|
||||
val eventSink: (DeveloperSettingsEvents) -> Unit
|
||||
) {
|
||||
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ import io.element.android.features.preferences.impl.developer.appsettings.AppDev
|
||||
import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
|
||||
open class DeveloperSettingsStateProvider : PreviewParameterProvider<DeveloperSettingsState> {
|
||||
@@ -40,6 +41,7 @@ fun aDeveloperSettingsState(
|
||||
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
isEnterpriseBuild: Boolean = false,
|
||||
showColorPicker: Boolean = false,
|
||||
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
|
||||
eventSink: (DeveloperSettingsEvents) -> Unit = {},
|
||||
) = DeveloperSettingsState(
|
||||
appDeveloperSettingsState = appDeveloperSettingsState,
|
||||
@@ -49,5 +51,6 @@ fun aDeveloperSettingsState(
|
||||
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
|
||||
isEnterpriseBuild = isEnterpriseBuild,
|
||||
showColorPicker = showColorPicker,
|
||||
deviceId = deviceId,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user