diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69152b7e2d..48f67099ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index f2658bb342..7560c36a06 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -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 diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 45e886f904..300058ec87 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -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 diff --git a/.github/workflows/gradle-wrapper-update.yml b/.github/workflows/gradle-wrapper-update.yml index 6b64a731d7..c64ab731a3 100644 --- a/.github/workflows/gradle-wrapper-update.yml +++ b/.github/workflows/gradle-wrapper-update.yml @@ -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 diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index cdc6fd3330..e2d37aadb9 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9beab4e01c..67c94da9f6 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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' diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 23cfffc265..929dd6680a 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -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 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e72ae0b4d1..14e5ee7a74 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -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@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + - uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 upload_reports: name: Project Check Suite diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index 00502d9612..44082bd347 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bc71a02ff..b9d8be16c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh index d29353a2c2..d04ee46f72 100755 --- a/.github/workflows/scripts/recordScreenshots.sh +++ b/.github/workflows/scripts/recordScreenshots.sh @@ -78,6 +78,10 @@ else git config --local user.name "${INPUT_AUTHOR_EMAIL}" fi git add -A +if git diff --cached --quiet; then + echo "Screenshots are already up to date, nothing to commit." + exit 0 +fi git commit -m "Update screenshots" GITHUB_REPO="https://$GITHUB_ACTOR:$TOKEN@github.com/$REPO.git" diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 6d9a62a2d1..26e24f3bd4 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -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 diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index 622fecc403..d8f57e3355 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -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 diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 61b4a8d521..939354f12f 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5ee7f5065d..d558baf73a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,12 +63,12 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }} run: git submodule update --init --recursive - name: ☕️ Use JDK 21 - uses: actions/setup-java@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' }} diff --git a/CHANGES.md b/CHANGES.md index 3f7360a338..2955fc8ad7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,95 @@ +Changes in Element X v26.08.1 +============================= + + + +## What's Changed +### 🙌 Improvements +* Render push notifications for live location share starts (MSC4505) by @ara4n in https://github.com/element-hq/element-x-android/pull/7203 +* [Task] Show active call participants in the people list #7199 by @Eklavya47 in https://github.com/element-hq/element-x-android/pull/7300 +### 🐛 Bugfixes +* Fix overlap issue. by @bmarty in https://github.com/element-hq/element-x-android/pull/7331 +* Announce checked/unchecked state for user rows in invite screen by @bmarty in https://github.com/element-hq/element-x-android/pull/7136 +* Display 'add reactions' menu in the threads screen by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7388 +* Expand the emoji picker to full height when the keyboard is shown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7382 +* UI fixes by @bmarty in https://github.com/element-hq/element-x-android/pull/7409 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7389 +### 🧱 Build +* Don't check enterprise previews in `KonsistPreviewTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7297 +* CI: split unit tests, screenshots and coverage in `tests.yml` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7296 +* Update Gradle Wrapper from 9.6.1 to 9.7.0 by @ElementBot in https://github.com/element-hq/element-x-android/pull/7335 +### 🚧 In development 🚧 +* QR code login iteration by @bmarty in https://github.com/element-hq/element-x-android/pull/6719 +* User status last pieces by @ganfra in https://github.com/element-hq/element-x-android/pull/7328 +* [Link new device] Add timeout by @bmarty in https://github.com/element-hq/element-x-android/pull/7338 +### Dependency upgrades +* Update dependency org.maplibre.compose:maplibre-compose to v0.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7291 +* Update android.gradle.plugin to v9.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7248 +* Update dependency io.sentry:sentry-android to v8.50.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7298 +* Update zizmorcore/zizmor-action action to v0.6.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7306 +* Update android.gradle.plugin to v9.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7304 +* Update dependency com.posthog:posthog-android to v3.56.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7311 +* Update dependencyAnalysis to v3.18.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7318 +* Update actions/stale action to v11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7317 +* Update dependency io.github.zxing-cpp:android to v3.1.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7322 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.05 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7324 +* Update dependency androidx.constraintlayout:constraintlayout to v2.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7329 +* Update reactivecircus/android-emulator-runner action to v2.38.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7189 +* Update dependency io.sentry:sentry-android to v8.51.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7301 +* Update dependency org.maplibre.gl:android-sdk to v13.4.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7305 +* Update dependency io.element.android:element-call-embedded to v0.23.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7323 +* Update metro to v1.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7334 +* Update dependency com.google.firebase:firebase-bom to v34.17.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7333 +* Update dependency androidx.compose.material3:material3 to v1.5.0-alpha23 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7175 +* Update dependency org.jsoup:jsoup to v1.23.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7332 +* Update dependency androidx.constraintlayout:constraintlayout-compose to v1.1.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7330 +* Update zizmorcore/zizmor-action action to v0.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7383 +* Update actions/setup-java action to v5.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7339 +* Update gradle/actions action to v6.3.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7384 +* Update plugin dependencycheck to v13 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7386 +* Update dependency org.matrix.rustcomponents:sdk-android to v26.08.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7402 +### 💼 Element Pro +* Add support for checking the ESS config in Element Pro by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7302 +### Others +* Add extra vertical padding to `ReplyToInvalidContent` and `ReplyToNotFoundContent` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7288 +* Index messages locally in an encrypted on-device store so encrypted rooms can be searched at all by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7249 +* Bump enterprise module to the latest commit by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7307 +* Bump enterprise module to the latest commit by @jennaharris7 in https://github.com/element-hq/element-x-android/pull/7309 +* Move deviceId and git details to developer settings by @bmarty in https://github.com/element-hq/element-x-android/pull/7241 +* Show a Message not found placeholder when a replied to message cannot be loaded by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7376 +* Update in reply to message when message is not found. by @bmarty in https://github.com/element-hq/element-x-android/pull/7387 +* Add default `OAUTH_CLIENT_URL_PATH` alternative by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7385 +* Fix the feature module template writing the impl build script into the api module by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7374 +* Let users enter their full Matrix user ID when searching for a homeserver by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7375 +* Keep manually revealed media visible when re-entering a room by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7378 +* Copy a blocked user's Matrix ID by long pressing them by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7372 +* Do not draw the media loading progress bar under the status bar by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7366 +* Do not notify about an event sent by another account on the same device by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7360 +* Keep the opened image on screen when the media gallery fails to load by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7362 +* Do not linkify the server part of a fediverse handle as an email address by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7356 +* Expose global account data get and set on MatrixClient by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7373 +* Show the alias or the direct message user's Matrix ID in the room picker by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7371 +* Use a dedicated string for the Mute room notification mode by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7367 +* Keep playing a voice message when the screen turns off by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7365 +* Let a voice message without a waveform be seeked by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7363 +* Ignore a second voice recording start while one is already running by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7346 +* Do not offer to start a call in a DM with no other member by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7352 +* Do not notify about a redaction whose target event is unknown by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7350 +* Ignore a room list filter that is incompatible with an already selected one by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7342 +* Stop a right to left sender name from reordering a room list preview by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7348 +* Do not append the application MapTiler key to a custom map style url by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7340 +* Hide space children you have left that can only be joined by invitation by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7369 +* Never re-encode a video at a higher bitrate than the source by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7351 +* Display push notifications for knocks by @ara4n in https://github.com/element-hq/element-x-android/pull/7207 + +## New Contributors +* @hayaksi1 made their first contribution in https://github.com/element-hq/element-x-android/pull/7249 +* @ara4n made their first contribution in https://github.com/element-hq/element-x-android/pull/7203 +* @Eklavya47 made their first contribution in https://github.com/element-hq/element-x-android/pull/7300 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.08.0...v26.08.1 + Changes in Element X v26.08.0 ============================= diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt index 6dd2143d95..82d2145ecc 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt @@ -35,4 +35,11 @@ object RageshakeConfig { * The maximum number of log lines a rageshake can contain. */ const val MAX_LOG_LINES_SIZE = 1_000_000 + + /** + * The maximum size of the description. The bug report server copies it verbatim into a GitHub issue body, + * which GitHub rejects above 65536 characters. The remaining budget is left to the other reported fields, + * mainly the list of the omitted log file names. + */ + const val MAX_DESCRIPTION_SIZE = 40 * 1024 } diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index ce9764360b..054e9525c7 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.wellknown.api) implementation(projects.features.login.api) implementation(libs.coil) @@ -60,6 +61,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) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 14230d7c5a..d7b768132b 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -11,8 +11,10 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope @@ -49,11 +51,13 @@ import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService +import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.features.location.api.LocalMapTilerConfig import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus @@ -153,6 +157,7 @@ class LoggedInFlowNode( private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, + private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -677,6 +682,12 @@ class LoggedInFlowNode( val colors by remember { enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) }.collectAsState(SemanticColorsLightDark.default) + + val currentMapTilerConfig = LocalMapTilerConfig.current + val updatedMapTilerConfig by produceState(currentMapTilerConfig) { + value = remoteEnterpriseConfigProvider.get(matrixClient.sessionId).dataOrNull()?.mapTilerConfig ?: currentMapTilerConfig + } + ElementThemeApp( appPreferencesStore = appPreferencesStore, featureFlagService = featureFlagService, @@ -684,16 +695,18 @@ class LoggedInFlowNode( compoundDark = colors.dark, buildMeta = buildMeta, ) { - val isOnline by syncService.isOnline.collectAsState() - ConnectivityIndicatorContainer( - isOnline = isOnline, - modifier = modifier, - ) { contentModifier -> - Box(modifier = contentModifier) { - val ftueState by ftueService.state.collectAsState() - BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) - if (ftueState is FtueState.Complete) { - PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { + val isOnline by syncService.isOnline.collectAsState() + ConnectivityIndicatorContainer( + isOnline = isOnline, + modifier = modifier, + ) { contentModifier -> + Box(modifier = contentModifier) { + val ftueState by ftueService.state.collectAsState() + BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack)) + if (ftueState is FtueState.Complete) { + PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) + } } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 0723ad4606..d46fb2f931 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -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 { 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) { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index faa19d9e61..cc330c2758 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -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 { } + 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 { } + 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 ReceiveTurbine.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(), ) } diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts index 5c71c170bf..396ef665eb 100644 --- a/codegen/build.gradle.kts +++ b/codegen/build.gradle.kts @@ -1,3 +1,5 @@ +import org.sonarqube.gradle.SonarResolverTask + /* * Copyright (c) 2025 Element Creations Ltd. * Copyright 2022-2025 New Vector Ltd. @@ -18,3 +20,9 @@ dependencies { implementation(libs.ksp.plugin) implementation(libs.kotlinpoet.ksp) } + +// Ensure that the processResources task is executed before the SonarResolverTask to avoid issues with missing resources during SonarQube analysis. +// This *should not be necesssary* but is a workaround for a known issue with the SonarQube Gradle plugin. +tasks.withType().configureEach { + dependsOn("processResources") +} diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 74020ead98..dbac9107c4 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -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 `Event.kt`, `State.kt`, + `StateProvider.kt`, `Presenter.kt`, `Node.kt` and `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 diff --git a/enterprise b/enterprise index 9b1ee4cd88..f5f910856c 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 9b1ee4cd8846eb30a1bc62be718e77eed281dfe6 +Subproject commit f5f910856cfd4f982d1dcce16a80f8fedfbcbd31 diff --git a/fastlane/metadata/android/en-US/changelogs/202608010.txt b/fastlane/metadata/android/en-US/changelogs/202608010.txt new file mode 100644 index 0000000000..a4b397f1bb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202608010.txt @@ -0,0 +1,2 @@ +Main changes in this version: bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt index e91c77079f..1158b57673 100644 --- a/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt +++ b/features/analytics/api/src/main/kotlin/io/element/android/features/analytics/api/preferences/AnalyticsPreferencesView.kt @@ -39,7 +39,7 @@ fun AnalyticsPreferencesView( ) Column(modifier) { ListItem( - headlineContent = { + content = { Text(stringResource(id = R.string.screen_analytics_settings_share_data)) }, supportingContent = { diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt index 42c66aa29d..23c748d6da 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/AnnouncementService.kt @@ -12,15 +12,34 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import kotlinx.coroutines.flow.Flow +/** + * Queues the full-screen announcements shown to the user once, such as the notice about a new feature. + * + * A dismissed announcement is remembered so it is not shown again. + */ interface AnnouncementService { + /** + * Queues an announcement, unless the user has already dismissed it. + * + * @param announcement the announcement to show. + */ suspend fun showAnnouncement(announcement: Announcement) + /** + * Records that the user dismissed an announcement, so it is not queued again. + * + * @param announcement the announcement that was dismissed. + */ suspend fun onAnnouncementDismissed(announcement: Announcement) + /** The announcements still waiting to be shown, in the order they should appear. */ fun announcementsToShowFlow(): Flow> /** * Use this composable to render the announcement UI in Fullscreen. + * Draws nothing while there is no announcement to show. + * + * @param modifier layout modifier for the container. */ @Composable fun Render( diff --git a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt index b5a5396b16..48b464cb21 100644 --- a/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt +++ b/features/cachecleaner/api/src/main/kotlin/io/element/android/features/cachecleaner/api/CacheCleaner.kt @@ -8,6 +8,9 @@ package io.element.android.features.cachecleaner.api +/** + * Removes the temporary files holding decrypted content, so plaintext media does not linger on disk. + */ interface CacheCleaner { /** * Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages). diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt index 25720596ba..20ff7ef4d8 100644 --- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallService.kt @@ -10,6 +10,9 @@ package io.element.android.features.call.api import kotlinx.coroutines.flow.StateFlow +/** + * Tracks the call the user is currently in on this device, so unrelated parts of the app can react to it. + */ interface CurrentCallService { /** * The current call state flow, which will be updated when the active call changes. diff --git a/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt index 68f7af28f1..3649768eb7 100644 --- a/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt +++ b/features/contentscanner/api/src/main/kotlin/io/element/android/features/contentscanner/api/ContentScannerService.kt @@ -18,5 +18,11 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal * This will only process media when a [ContentScanner] is provided when building the client in Pro. */ fun interface ContentScannerService { + /** + * Starts scanning the given media, reporting the verdict of each one through [contentValidationState] rather than by returning. + * + * @param mediaSources the media to scan. + * @param contentValidationState where the outcome of each scan is published for the UI to observe. + */ fun scan(mediaSources: List, contentValidationState: ContentValidationState) } diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt index 271fdb2332..3128b8073a 100644 --- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt @@ -15,9 +15,21 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId interface CreateRoomEntryPoint : FeatureEntryPoint { + /** + * Configures the create-room flow before it is started, since it serves rooms and spaces from the same screens. + */ interface Builder { + /** + * @param isSpace true to create a space rather than a room. + */ fun setIsSpace(isSpace: Boolean): Builder + + /** + * @param parentSpaceId the space the new room should be added to once created. + */ fun setParentSpace(parentSpaceId: RoomId): Builder + + /** Builds the node with the options configured so far. */ fun build(): Node } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index 1ee4bcd2b5..c929060e13 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -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) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt index c96fbc4d6b..6389f920d5 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/SelectParentSpaceOptions.kt @@ -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 diff --git a/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml b/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml index a099a67f7f..159d8f4a41 100644 --- a/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/deactivation/impl/src/main/res/values-zh-rTW/translations.xml @@ -1,14 +1,14 @@ - "請確認您想要停用您的帳號。此動作無法還原。" + "請確認您想要刪除您的帳號。此動作無法還原。" "刪除我所有的訊息" "警告:未來的使用者可能會看到不完整的對話。" - "停用您的帳號為 %1$s,它將:" + "刪除您的帳號為 %1$s,它將:" "不可逆" "%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。" "永久停用" "將您從所有聊天室移除。" "從我們的身份伺服器將您的帳號資訊刪除。" "註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。" - "停用帳號" + "刪除帳號" diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt index 7b595966d4..7d23ad0ea1 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/EnterpriseService.kt @@ -10,15 +10,46 @@ 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 +/** + * Exposes the restrictions and customisations an enterprise deployment can impose: allowed homeservers, branding, push gateways and more. + * + * On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves. + */ interface EnterpriseService { + /** Whether this build is an enterprise one; `false` for the public app. */ val isEnterpriseBuild: Boolean + + /** + * Whether the given session belongs to an enterprise deployment, which can be true even on a standard build. + * + * @param sessionId the session to check. + */ suspend fun isEnterpriseUser(sessionId: SessionId): Boolean - suspend fun tweakMasUrl(url: String, homeserver: String): String + + /** + * Rewrites the authentication server URL when the deployment requires a different one from the one advertised. + * + * @param url the URL to rewrite. + * @param homeserver the homeserver the URL belongs to. + * @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works. + */ + suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String + + /** + * The homeservers the user is allowed to sign in to; an empty list, or one containing [ANY_ACCOUNT_PROVIDER], means no restriction. + */ fun defaultHomeserverList(): List + + /** + * Whether the user is allowed to sign in to a given homeserver, according to [defaultHomeserverList]. + * + * @param homeserverUrl the server the user is trying to use. + */ suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean /** @@ -28,17 +59,38 @@ interface EnterpriseService { */ suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) + /** + * The brand color to apply, or `null` when the default theme color should be used. + * + * @param sessionId the session to read the color of, or `null` for the color used when no session is active. + */ fun brandColorsFlow(sessionId: SessionId?): Flow + /** + * The full light and dark color palettes derived from the brand color. + * + * @param sessionId the session to read the colors of, or `null` for the colors used when no session is active. + */ fun semanticColorsFlow(sessionId: SessionId?): Flow + /** The push gateway to register with when using Firebase, or `null` to use the app default. */ fun firebasePushGateway(): String? + + /** The push gateway to register with when using UnifiedPush, or `null` to use the app default. */ fun unifiedPushDefaultPushGateway(): String? + /** + * Where bug reports should be submitted, which an enterprise deployment can redirect or disable entirely. + * + * @param sessionId the session to read the setting of, or `null` for the setting used when no session is active. + */ fun bugReportUrlFlow(sessionId: SessionId?): Flow /** * Gets Notification Channel to use for the noisy notifications of the provided session. + * Returns `null` when the session has no dedicated channel and the app-wide one should be used. + * + * @param sessionId the session whose channel is requested. */ fun getNoisyNotificationChannelId(sessionId: SessionId): String? @@ -47,6 +99,14 @@ interface EnterpriseService { */ fun overriddenElementWellKnown(): ElementWellKnown? + /** + * Gets the Element Server Suite (ESS) config endpoint URL for the given domain. + * Returns `null` when this build does not read its configuration from an ESS deployment. + * + * @param domain the server whose configuration endpoint is requested. + */ + fun essConfigEndpointUrl(domain: String): String? + companion object { const val ANY_ACCOUNT_PROVIDER = "*" } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index ee005a0b6b..84067ce56f 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -8,11 +8,25 @@ package io.element.android.features.enterprise.api +/** + * The session-scoped counterpart of [EnterpriseService], answering the same kind of questions for the session it belongs to. + * + * [init] must have run before the other members return meaningful answers. + */ interface SessionEnterpriseService { + /** Whether Element Call may be used on this session, which a deployment can turn off. */ suspend fun isElementCallAvailable(): Boolean + + /** + * Rewrites the authentication server URL for this session; see [EnterpriseService.tweakMasUrl]. + * + * @param url the URL to rewrite. + */ suspend fun tweakMasUrl(url: String): String + /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean + /** Loads the deployment configuration of this session; to be called once when the session starts. */ suspend fun init() } diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt new file mode 100644 index 0000000000..c9adf21eec --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfig.kt @@ -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 diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt new file mode 100644 index 0000000000..4507b1a72a --- /dev/null +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/remoteconfig/RemoteEnterpriseConfigProvider.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.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 { + /** + * Returns the configuration of a session, served from the cache when possible. + * A stale entry is reported as outdated while a refresh runs in the background, so this rarely waits on the network. + * + * @param sessionId the session whose configuration is requested. + */ + suspend fun get(sessionId: SessionId): WellknownRetrieverResult + + /** + * Creates a provider, which needs a fetcher because the configuration is read over HTTP. + */ + interface Factory { + /** + * @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works. + */ + fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider + } +} diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index bc2a3f1bf1..c75a2cb527 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -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 = 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 } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt new file mode 100644 index 0000000000..fbc5cd5827 --- /dev/null +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/remoteconfig/DefaultRemoteEnterpriseConfigProvider.kt @@ -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 { + return WellknownRetrieverResult.NotFound + } + + @ContributesBinding(AppScope::class) + class Factory : RemoteEnterpriseConfigProvider.Factory { + override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider { + return DefaultRemoteEnterpriseConfigProvider() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt similarity index 51% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt rename to features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt index b43dcfb3e8..4d4a800de9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/scanner/DefaultContentScannerUrlProvider.kt @@ -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 = Result.success(null) + override suspend fun getContentScannerUrl(sessionId: SessionId): Result = Result.success(null) + + @ContributesBinding(AppScope::class) + class Factory : ContentScannerUrlProvider.Factory { + override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider { + return DefaultContentScannerUrlProvider() + } + } } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt index f6379bef30..858c46e3bf 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeEnterpriseService.kt @@ -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 { @@ -85,4 +87,8 @@ class FakeEnterpriseService( override fun overriddenElementWellKnown(): ElementWellKnown? { return overrideWellKnownResult() } + + override fun essConfigEndpointUrl(domain: String): String { + return essConfigEndpointUrlResult(domain) + } } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt new file mode 100644 index 0000000000..2c57dbb530 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProvider.kt @@ -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 = { WellknownRetrieverResult.NotFound } +) : RemoteEnterpriseConfigProvider { + override suspend fun get(sessionId: SessionId): WellknownRetrieverResult { + return getResult(sessionId) + } +} diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt new file mode 100644 index 0000000000..a49bbba279 --- /dev/null +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/remoteconfig/FakeRemoteEnterpriseConfigProviderFactory.kt @@ -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 +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 50cfc00a78..f5a2400f2f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -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 diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt index 8163d29d92..63d28dca21 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/HomeTopBar.kt @@ -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 diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt index 847dbc2c39..9b5951ae3d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/selection/DefaultFilterSelectionStrategy.kt @@ -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() } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt index 9d6a5173ae..bfa6962e8b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContextMenu.kt @@ -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() }, diff --git a/features/home/impl/src/main/res/values-pl/translations.xml b/features/home/impl/src/main/res/values-pl/translations.xml index 0c1b07f27b..f6e2e70519 100644 --- a/features/home/impl/src/main/res/values-pl/translations.xml +++ b/features/home/impl/src/main/res/values-pl/translations.xml @@ -5,7 +5,7 @@ "Powiadomienia nie dochodzą?" "Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy." "Odświeżyliśmy Twoje dźwięki" - "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." + "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." "Uzyskaj klucz przywracania" "Utwórz kopię zapasową swoich czatów" "Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości." diff --git a/features/home/impl/src/main/res/values-ro/translations.xml b/features/home/impl/src/main/res/values-ro/translations.xml index 14cf100c22..2446ebd495 100644 --- a/features/home/impl/src/main/res/values-ro/translations.xml +++ b/features/home/impl/src/main/res/values-ro/translations.xml @@ -5,7 +5,7 @@ "Nu primiți notificări?" "Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar." "Am reîmprospătat sunetele" - "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." + "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." "Obțineți cheia de recuperare" "Faceți un backup al mesajelor" "Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup." diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt index 9fb87c0eec..02d947613f 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersPresenterTest.kt @@ -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 { diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt index 046303ca4f..d8ab7c71c8 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/SeenInvitesStore.kt @@ -11,6 +11,9 @@ package io.element.android.features.invite.api import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.Flow +/** + * Remembers which invitations the user has already looked at, so that only genuinely new ones are highlighted in the room list. + */ interface SeenInvitesStore { /** * Returns a flow of seen room IDs of invitation. @@ -32,7 +35,7 @@ interface SeenInvitesStore { suspend fun markAsUnSeen(roomId: RoomId) /** - * Delete the store. + * Delete the store, so every invitation counts as unseen again. */ suspend fun clear() } diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt index 2caaacab2b..5691b19ef7 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteEvents.kt @@ -10,6 +10,9 @@ package io.element.android.features.invite.api.acceptdecline import io.element.android.features.invite.api.InviteData +/** + * Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation. + */ interface AcceptDeclineInviteEvents { data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt index b6eec03a6f..6e9d78082b 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/acceptdecline/AcceptDeclineInviteView.kt @@ -12,7 +12,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.element.android.libraries.matrix.api.core.RoomId +/** + * Renders the confirmation dialogs and error states of accepting or declining an invitation; it draws nothing until such an event is sent. + * + * Embedded by every screen that can act on an invitation, so the behaviour stays identical between the invite list and a room preview. + */ fun interface AcceptDeclineInviteView { + /** + * Draws whichever dialog the current state calls for. + * + * @param state the state produced by the accept/decline presenter. + * @param onAcceptInviteSuccess called once the room has been joined, so the host screen can navigate into it. + * @param onDeclineInviteSuccess called once the invitation has been declined. + * @param modifier layout modifier for the container. + */ @Composable fun Render( state: AcceptDeclineInviteState, diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt index 1028a4c142..1d14df024a 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/declineandblock/DeclineAndBlockView.kt @@ -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 = { diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt index 0422fac4f1..9570ab8152 100644 --- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleEvents.kt @@ -8,6 +8,9 @@ package io.element.android.features.invitepeople.api +/** + * Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink]. + */ interface InvitePeopleEvents { data object SendInvites : InvitePeopleEvents data object CloseSearch : InvitePeopleEvents diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt index 0be0798680..1863397e48 100644 --- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeoplePresenter.kt @@ -13,7 +13,14 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.JoinedRoom interface InvitePeoplePresenter : Presenter { + /** + * Creates a presenter for one invite flow. + */ interface Factory { + /** + * @param joinedRoom the room to invite into, or `null` when the room does not exist yet, as when inviting from a direct message. + * @param roomId the id of the room to invite into. + */ fun create( joinedRoom: JoinedRoom?, roomId: RoomId, diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt index 7bad81ab65..0bbaa45a2f 100644 --- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleRenderer.kt @@ -11,7 +11,16 @@ package io.element.android.features.invitepeople.api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +/** + * Renders the invite people UI, so that other screens can embed it without depending on its implementation. + */ interface InvitePeopleRenderer { + /** + * Draws the invite list and its search field. + * + * @param state the state produced by [InvitePeoplePresenter]. + * @param modifier layout modifier for the container. + */ @Composable fun Render( state: InvitePeopleState, diff --git a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt index d14042cff7..0863ec7212 100644 --- a/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt +++ b/features/invitepeople/api/src/main/kotlin/io/element/android/features/invitepeople/api/InvitePeopleState.kt @@ -11,10 +11,22 @@ package io.element.android.features.invitepeople.api import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +/** + * State of the invite people UI, produced by an [InvitePeoplePresenter] and consumed by an [InvitePeopleRenderer]. + */ interface InvitePeopleState { + /** Whether the send button should be enabled, i.e. at least one user is selected and the user has the permission. */ val canInvite: Boolean + + /** Whether the search field currently has focus, which the host screen uses to adapt its own layout. */ val isSearchActive: Boolean + + /** Progress of the invite request, so the UI can show a loader and surface failures. */ val sendInvitesAction: AsyncAction + + /** Progress of turning a direct message into a room, which happens when inviting a third person into a DM. */ val createRoomFromDmAction: AsyncAction + + /** Where the UI sends its events. */ val eventSink: (InvitePeopleEvents) -> Unit } diff --git a/features/invitepeople/impl/src/main/res/values-de/translations.xml b/features/invitepeople/impl/src/main/res/values-de/translations.xml index 182d9f289a..d0c2da50b4 100644 --- a/features/invitepeople/impl/src/main/res/values-de/translations.xml +++ b/features/invitepeople/impl/src/main/res/values-de/translations.xml @@ -2,4 +2,8 @@ "Bereits Mitglied" "Bereits eingeladen" + "Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst." + "Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst." + "Möchtest du neue Kontakte in diesen Chat einladen?" + "Willst du einen neuen Kontakt in diesen Chat einladen?" diff --git a/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml b/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml index c8117ec283..5197f7758c 100644 --- a/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/invitepeople/impl/src/main/res/values-zh-rTW/translations.xml @@ -2,4 +2,8 @@ "已是成員" "已邀請" + "您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。" + "您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。" + "要邀請新聯絡人加入此聊天室嗎?" + "要邀請新聯絡人加入此聊天室嗎?" diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt index 34061db335..e6be68b95a 100644 --- a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt +++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/banner/KnockRequestsBannerRenderer.kt @@ -11,7 +11,16 @@ package io.element.android.features.knockrequests.api.banner import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +/** + * Renders the banner shown at the top of a room when people are waiting to join it. + */ interface KnockRequestsBannerRenderer { + /** + * Draws the banner, or nothing when there is no pending request or the user cannot moderate the room. + * + * @param modifier layout modifier for the container. + * @param onViewRequestsClick called when the user asks to see the full list of requests. + */ @Composable fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) } diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt index 6d228ee887..f0c3eaf7bf 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomEvent.kt @@ -10,6 +10,9 @@ package io.element.android.features.leaveroom.api import io.element.android.libraries.matrix.api.core.RoomId +/** + * Events sent to the leave room presenter through [LeaveRoomState.eventSink]. + */ interface LeaveRoomEvent { data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent } diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt index 852193d874..8472e098ec 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomRenderer.kt @@ -12,7 +12,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.element.android.libraries.matrix.api.core.RoomId +/** + * Renders the confirmation dialogs of the leave room flow; it draws nothing until the host screen sends a leave event. + */ fun interface LeaveRoomRenderer { + /** + * Draws whichever dialog the current state calls for. + * + * @param state the state produced by the leave room presenter. + * @param onSelectNewOwners called when the user is the last owner of a room that still has other members, and must promote someone before leaving. + * @param modifier layout modifier for the container. + */ @Composable fun Render( state: LeaveRoomState, diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt index 92a5916858..b9150b59f0 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomState.kt @@ -10,7 +10,11 @@ package io.element.android.features.leaveroom.api import androidx.compose.runtime.Immutable +/** + * State of the leave room flow, which is only the confirmation dialogs: the screen it is hosted in owns the rest of the UI. + */ @Immutable interface LeaveRoomState { + /** Where the host screen sends its events to start or confirm leaving a room. */ val eventSink: (LeaveRoomEvent) -> Unit } diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt index e1d445ea1f..14b195a0b5 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/details/DependenciesDetailsView.kt @@ -62,7 +62,7 @@ fun DependenciesDetailsView( } } ListItem( - headlineContent = { + content = { ClickableLinkText( text = text, interactionSource = remember { MutableInteractionSource() }, diff --git a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt index b732588217..943f57ab3b 100644 --- a/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt +++ b/features/licenses/impl/src/main/kotlin/io/element/android/features/licenses/impl/list/DependencyLicensesListView.kt @@ -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 { diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt index a8a8ff14b6..7afb9a7623 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -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.") diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt index d686b22860..afa570f0a9 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -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) { diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt index 20bd50f488..5110f8938f 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt @@ -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 diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt index 21071a6831..1a5eac0522 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenter.kt @@ -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 { @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 - } } diff --git a/features/linknewdevice/impl/src/main/res/values-et/translations.xml b/features/linknewdevice/impl/src/main/res/values-et/translations.xml index 10f8af5e11..95f9f5f586 100644 --- a/features/linknewdevice/impl/src/main/res/values-et/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-et/translations.xml @@ -5,6 +5,7 @@ "Skaneeri QR-koodi selle seadmega" "Skaneerimiseks valmis" "QR-koodi laadimiseks ava %1$s süle- või lauaarvutis" + "Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine." "Numbrid ei klapi" "Sisesta kahekohaline kood" "Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline." diff --git a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml index 8561e63ad3..b77fcc5088 100644 --- a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml @@ -5,6 +5,7 @@ "Skenirajte QR kod ovim uređajem" "Spremno za skeniranje" "Otvorite %1$s na stolnom računalu kako biste dobili QR kod" + "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." "Brojevi se ne podudaraju" "Unesite dvoznamenkasti kod" "Time ćete potvrditi da je veza s vašim drugim uređajem sigurna." diff --git a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml index 8cefed94cc..49556cf43a 100644 --- a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml @@ -5,6 +5,7 @@ "Olvassa be a QR-kódot ezzel az eszközzel" "Készen áll a beolvasásra" "Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot" + "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." "A számok nem egyeznek" "Írja be a kétjegyű kódot" "Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e." diff --git a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml index 57d439240d..5ba904c7fc 100644 --- a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml @@ -5,6 +5,7 @@ "Scanați codul QR cu acest dispozitiv" "Gata de scanare" "Deschide %1$s pe un computer desktop pentru a obține codul QR" + "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ă." "Numerele nu se potrivesc" "Introduceți codul de 2 cifre" "Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură." diff --git a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml index e18fbc13b6..04bea08c86 100644 --- a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml @@ -5,6 +5,7 @@ "使用此裝置掃描 QR code" "準備掃描" "在桌上型電腦上開啟 %1$s 以取得 QR code" + "請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。" "數字不符" "輸入兩位數代碼" "這將確認您與另一台裝置之間的連線是否安全。" diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt index f92cf66102..db7c3e8c01 100644 --- a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodePresenterTest.kt @@ -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, ) } diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index f8377389f1..b3eeacf87f 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.wellknown.api) implementation(libs.coil.compose) implementation(libs.datetime) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt new file mode 100644 index 0000000000..a2212119f5 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -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.location.api + +import androidx.compose.runtime.staticCompositionLocalOf +import io.element.android.libraries.wellknown.api.MapTilerConfig + +/** + * Provides a [MapTilerConfig] for the MapLibre maps. + */ +val LocalMapTilerConfig = staticCompositionLocalOf { + MapTilerConfig( + apiKey = BuildConfig.MAPTILER_API_KEY, + lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = BuildConfig.MAPTILER_BASE_URL + ) +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt index 22049c9548..41c58c396f 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt @@ -8,6 +8,10 @@ package io.element.android.features.location.api +/** + * Tells whether the location features can be used at all, which depends on the build having a map provider configured. + */ interface LocationService { + /** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */ fun isServiceAvailable(): Boolean } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index c16b1ae560..33e454722f 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -38,13 +38,14 @@ import coil3.request.ImageRequest import coil3.request.SuccessResult import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.internal.StaticMapPlaceholder -import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.features.location.api.internal.rememberStaticMapBuilder import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.wellknown.api.MapTilerConfig /** * Shows a static map image downloaded via a third party service's static maps API. @@ -57,6 +58,7 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables */ @Composable fun StaticMapView( + mapTilerConfig: MapTilerConfig?, location: Location?, zoom: Double, pinVariant: PinVariant, @@ -94,6 +96,7 @@ fun StaticMapView( } // Cases 3 & 4: Non-null location - fetch map else -> LoadableMapContent( + mapTilerConfig = mapTilerConfig, location = location, zoom = zoom, pinVariant = pinVariant, @@ -124,6 +127,7 @@ private fun BoxWithConstraintsScope.StaleMapContent( @Composable private fun BoxWithConstraintsScope.LoadableMapContent( + mapTilerConfig: MapTilerConfig?, location: Location, zoom: Double, pinVariant: PinVariant, @@ -132,7 +136,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent( ) { val context = LocalContext.current var retryHash by remember { mutableIntStateOf(0) } - val builder = remember { StaticMapUrlBuilder() } + val builder = rememberStaticMapBuilder(mapTilerConfig) val (painter, state, contentScale) = if (LocalInspectionMode.current) { val painter = painterResource(CommonDrawables.sample_map) @@ -203,6 +207,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent( @Composable internal fun StaticMapViewPreview() = ElementPreview { StaticMapView( + mapTilerConfig = null, location = Location(0.0, 0.0), zoom = 0.0, contentDescription = null, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index 666ca07a08..78eae2f633 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.api.internal import io.element.android.features.location.api.BuildConfig +import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlin.math.roundToInt /** @@ -29,6 +30,13 @@ internal class MapTilerStaticMapUrlBuilder( darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, ) + constructor(mapTilerConfig: MapTilerConfig) : this( + baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"), + apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY, + lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID, + darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID, + ) + override fun build( lat: Double, lon: Double, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index 054805d1ab..10aef90116 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -11,6 +11,7 @@ package io.element.android.features.location.api.internal import io.element.android.features.location.api.BuildConfig +import io.element.android.libraries.wellknown.api.MapTilerConfig internal class MapTilerTileServerStyleUriBuilder( private val baseUrl: String, @@ -26,17 +27,17 @@ internal class MapTilerTileServerStyleUriBuilder( ) override fun build( - customMapStyleUrl: String?, + customMapTilerConfig: MapTilerConfig?, 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") + val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl + val apiKey = customMapTilerConfig?.apiKey ?: apiKey + val mapId = if (darkMode) { + customMapTilerConfig?.darkStyleId ?: darkMapId + } else { + customMapTilerConfig?.lightStyleId ?: lightMapId } + + return "$baseUrl/$mapId/style.json?key=$apiKey" } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 6636976828..212da08647 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -8,10 +8,25 @@ package io.element.android.features.location.api.internal +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.element.android.libraries.wellknown.api.MapTilerConfig + /** * Builds an URL for a 3rd party service provider static maps API. */ interface StaticMapUrlBuilder { + /** + * Builds the URL of a map image centred on the given coordinates. + * + * @param lat latitude of the centre of the map. + * @param lon longitude of the centre of the map. + * @param zoom zoom level to render at. + * @param darkMode whether to request the dark map style. + * @param width width of the image in density independent pixels. + * @param height height of the image in density independent pixels. + * @param density screen density, used to request a matching pixel size. + */ fun build( lat: Double, lon: Double, @@ -22,7 +37,17 @@ interface StaticMapUrlBuilder { density: Float, ): String + /** Whether a map provider is configured for this build; the URLs are unusable when it is not. */ fun isServiceAvailable(): Boolean } -fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder() +@Composable +fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder { + return remember(mapTilerConfig) { + if (mapTilerConfig != null) { + MapTilerStaticMapUrlBuilder(mapTilerConfig) + } else { + MapTilerStaticMapUrlBuilder() + } + } +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 762d580f8e..4cfc8c4a2f 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -11,6 +11,7 @@ package io.element.android.features.location.api.internal import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.wellknown.api.MapTilerConfig /** * Builds a style URI for a MapLibre compatible tile server. @@ -18,8 +19,14 @@ import io.element.android.compound.theme.ElementTheme * Used for rendering dynamic maps. */ interface TileServerStyleUriBuilder { + /** + * Builds the style URI the map view loads its tiles from. + * + * @param customMapStyleUrl the style advertised by the homeserver, or `null` to use the one built into the app. + * @param darkMode whether to request the dark map style. + */ fun build( - customMapStyleUrl: String?, + customMapTilerConfig: MapTilerConfig?, darkMode: Boolean, ): String } @@ -31,12 +38,12 @@ interface TileServerStyleUriBuilder { */ @Composable fun rememberTileStyleUrl( - customMapStyleUrl: String?, + customMapTileConfig: MapTilerConfig?, ): String { val darkMode = !ElementTheme.isLightTheme - return remember(darkMode, customMapStyleUrl) { + return remember(darkMode, customMapTileConfig) { MapTilerTileServerStyleUriBuilder().build( - customMapStyleUrl = customMapStyleUrl, + customMapTilerConfig = customMapTileConfig, darkMode = darkMode, ) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt index cd6b8731c1..64c28209ca 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/live/ActiveLiveLocationShareManager.kt @@ -12,6 +12,9 @@ import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.StateFlow import kotlin.time.Duration +/** + * Owns the live location shares running on this device, across every room, and the foreground service that feeds them GPS updates. + */ interface ActiveLiveLocationShareManager { /** All rooms currently sharing live location on this device. */ val sharingRoomIds: StateFlow> @@ -27,6 +30,9 @@ interface ActiveLiveLocationShareManager { * Starts live location sharing in the given room. * Calls room.startLiveLocationShare() on the SDK, registers the share, * and starts the foreground GPS service if not already running. + * + * @param roomId the room to start sharing the location in. + * @param duration how long the share should last before it expires on its own. */ suspend fun startShare(roomId: RoomId, duration: Duration): Result @@ -34,6 +40,8 @@ interface ActiveLiveLocationShareManager { * Stops live location sharing in the given room. * Calls room.stopLiveLocationShare() on the SDK, removes the share, * and stops the foreground service if no shares remain. + * + * @param roomId the room to stop sharing the location in. */ suspend fun stopShare(roomId: RoomId): Result } diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index 2e07204594..09aae4f310 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.wellknown.api.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { @@ -23,7 +24,7 @@ class MapTilerTileServerStyleUriBuilderTest { fun `light map uri`() { assertThat( builder.build( - customMapStyleUrl = null, + customMapTilerConfig = null, darkMode = false, ) ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") @@ -33,7 +34,7 @@ class MapTilerTileServerStyleUriBuilderTest { fun `dark map uri`() { assertThat( builder.build( - customMapStyleUrl = null, + customMapTilerConfig = null, darkMode = true, ) ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") @@ -43,19 +44,44 @@ class MapTilerTileServerStyleUriBuilderTest { fun `custom map uri light`() { assertThat( builder.build( - customMapStyleUrl = "https://custom.url/style.json", + customMapTilerConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/style.json" + ), darkMode = false, ) - ).isEqualTo("https://custom.url/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY") } @Test fun `custom map uri dark`() { assertThat( builder.build( - customMapStyleUrl = "https://custom.url/style.json", + customMapTilerConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/style.json" + ), darkMode = true, ) - ).isEqualTo("https://custom.url/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY") + } + + @Test + fun `blank custom map uri falls back to the built-in style`() { + assertThat( + builder.build( + customMapTilerConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = " " + ), + darkMode = false, + ) + ).isEqualTo("https://base.url/light/style.json?key=API_KEY") } } diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 165c32b7c5..43e4f58cab 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -26,6 +26,7 @@ setupDependencyInjection() dependencies { api(projects.features.location.api) + implementation(projects.features.enterprise.api) implementation(projects.features.messages.api) implementation(libs.maplibre.compose) implementation(libs.coil) @@ -45,6 +46,7 @@ dependencies { implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) + implementation(projects.libraries.wellknown.api) implementation(libs.androidx.datastore.preferences) implementation(libs.datetime) @@ -58,5 +60,7 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.wellknown.test) testImplementation(projects.features.location.test) + testImplementation(projects.features.enterprise.test) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 0eff1b6028..eaa238bed0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -50,6 +50,7 @@ import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.wellknown.api.MapTilerConfig import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapOptions @@ -66,7 +67,7 @@ import kotlin.math.roundToInt * - Updating camera position padding based on sheet height * - Rendering the MaplibreMap with proper ornament positioning * - * @param customMapStyleUrl Optional custom style URL for the map + * @param customMapTilerConfig Optional custom style URL for the map * @param modifier Modifier for the root layout * @param scaffoldState State for the bottom sheet scaffold * @param cameraState The camera state for the map @@ -83,7 +84,7 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun MapBottomSheetScaffold( - customMapStyleUrl: AsyncData, + customMapTilerConfig: AsyncData, modifier: Modifier = Modifier, scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( @@ -137,11 +138,11 @@ fun MapBottomSheetScaffold( val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) Box { - when (customMapStyleUrl) { + when (customMapTilerConfig) { is AsyncData.Success -> { MaplibreMap( options = mapOptions, - baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapStyleUrl.data)), + baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), modifier = Modifier.fillMaxSize(), cameraState = cameraState, content = mapContent, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index a3f3cac432..d388e0c5d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -22,6 +22,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -72,6 +73,7 @@ class ShareLocationPresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationStore: LiveLocationStore, private val userLocationStateFactory: UserLocationState.Factory, + private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, ) : Presenter { @AssistedFactory fun interface Factory { @@ -92,9 +94,9 @@ class ShareLocationPresenter( var pendingLiveLocationShare by remember { mutableStateOf(false) } val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val currentUser by client.userProfile.collectAsState() - val customMapStyleUrl by produceState(AsyncData.Loading()) { + val customMapConfig by produceState(AsyncData.Loading()) { // Ignore errors - value = AsyncData.Success(client.getMapStyleUrl().getOrNull()) + value = AsyncData.Success(remoteEnterpriseConfigProvider.get(client.sessionId).dataOrNull()?.mapTilerConfig) } val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> perms.sendLiveLocationPermissions() @@ -199,7 +201,7 @@ class ShareLocationPresenter( } return ShareLocationState( - customMapStyleUrl = customMapStyleUrl, + customMapTilerConfig = customMapConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index fb1b803435..d2cce94ef8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -13,10 +13,11 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( - val customMapStyleUrl: AsyncData, + val customMapTilerConfig: AsyncData, val currentUser: MatrixUser, val dialogState: Dialog, val trackUserLocation: Boolean, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index ed8d46afd3..6d7ff5e612 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.persistentListOf import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -70,13 +71,13 @@ class ShareLocationStateProvider : PreviewParameterProvider canShareLiveLocation = true, ), aShareLocationState( - customMapStyleUrl = AsyncData.Loading(), + customMapTilerConfig = AsyncData.Loading(), ), ) } fun aShareLocationState( - customMapStyleUrl: AsyncData = AsyncData.Success(null), + customMapTilerConfig: AsyncData = AsyncData.Success(null), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, trackUserPosition: Boolean = false, @@ -87,7 +88,7 @@ fun aShareLocationState( eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( - customMapStyleUrl = customMapStyleUrl, + customMapTilerConfig = customMapTilerConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, @@ -95,6 +96,6 @@ fun aShareLocationState( canShareLiveLocation = canShareLiveLocation, appName = appName, startLiveLocationAction = startLiveLocationAction, - eventSink = eventSink + eventSink = eventSink, ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index dc857b37a6..53e7874936 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -118,7 +118,7 @@ fun ShareLocationView( } MapBottomSheetScaffold( - customMapStyleUrl = state.customMapStyleUrl, + customMapTilerConfig = state.customMapTilerConfig, cameraState = cameraState, modifier = modifier, scaffoldState = scaffoldState, @@ -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, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index f187e91c53..2d9a4900dd 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.live.ActiveLiveLocationShareManager @@ -46,7 +47,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.getBestName import io.element.android.libraries.matrix.api.room.joinedRoomMembers @@ -64,10 +65,11 @@ import kotlinx.coroutines.launch private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, private val stringProvider: StringProvider, - private val client: MatrixClient, + private val sessionId: SessionId, private val joinedRoom: JoinedRoom, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val userLocationStateFactory: UserLocationState.Factory, + private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider, ) : Presenter { @AssistedFactory fun interface Factory { fun create(mode: ShowLocationMode): ShowLocationPresenter @@ -85,9 +87,9 @@ import kotlinx.coroutines.launch mutableStateOf(LocationConstraintsDialogState.None) } - val customMapStyleUrl by produceState(AsyncData.Loading()) { + val customMapTilerConfig by produceState(AsyncData.Loading()) { // Ignore errors - value = AsyncData.Success(client.getMapStyleUrl().getOrNull()) + value = AsyncData.Success(remoteEnterpriseConfigProvider.get(sessionId).dataOrNull()?.mapTilerConfig) } fun checkLocationConstraints() { @@ -220,7 +222,7 @@ import kotlinx.coroutines.launch userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) } return ShowLocationState( - customMapStyleUrl = customMapStyleUrl, + customMapTilerConfig = customMapTilerConfig, dialogState = dialogState, locationShares = locationShares, focusedLocation = focusedLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index e5ddf589b0..2ae76ef9e9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -17,10 +17,11 @@ import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( - val customMapStyleUrl: AsyncData, + val customMapTilerConfig: AsyncData, val isLive: Boolean, val dialogState: LocationConstraintsDialogState, val locationShares: ImmutableList, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index cd7ea108bb..a1f349d644 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.wellknown.api.MapTilerConfig import kotlinx.collections.immutable.toImmutableList class ShowLocationStateProvider : PreviewParameterProvider { @@ -36,14 +37,14 @@ class ShowLocationStateProvider : PreviewParameterProvider { constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled, ), aShowLocationState(isTrackMyLocation = true), - aShowLocationState(customMapStyleUrl = AsyncData.Loading()), + aShowLocationState(customMapTilerConfig = AsyncData.Loading()), ) } private const val APP_NAME = "ApplicationName" fun aShowLocationState( - customMapStyleUrl: AsyncData = AsyncData.Success(null), + customMapTilerConfig: AsyncData = AsyncData.Success(null), isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, locationShares: List = listOf(aLocationShareItem(isLive = isLive)), @@ -55,7 +56,7 @@ fun aShowLocationState( eventSink: (ShowLocationEvent) -> Unit = {}, ): ShowLocationState { return ShowLocationState( - customMapStyleUrl = customMapStyleUrl, + customMapTilerConfig = customMapTilerConfig, dialogState = constraintsDialogState, locationShares = locationShares.toImmutableList(), focusedLocation = focusedLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 9fdbe3aac8..45e4c7f4cc 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -102,7 +102,7 @@ fun ShowLocationView( } } MapBottomSheetScaffold( - customMapStyleUrl = state.customMapStyleUrl, + customMapTilerConfig = state.customMapTilerConfig, sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } } else { diff --git a/features/location/impl/src/main/res/values-de/translations.xml b/features/location/impl/src/main/res/values-de/translations.xml index 1a1e208c3b..1d10fea641 100644 --- a/features/location/impl/src/main/res/values-de/translations.xml +++ b/features/location/impl/src/main/res/values-de/translations.xml @@ -1,4 +1,5 @@ + "Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast." "Wie lange soll der Live-Standort geteilt werden?" diff --git a/features/location/impl/src/main/res/values-zh-rTW/translations.xml b/features/location/impl/src/main/res/values-zh-rTW/translations.xml index 27f507732d..d7647730de 100644 --- a/features/location/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/location/impl/src/main/res/values-zh-rTW/translations.xml @@ -2,4 +2,5 @@ "您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。" "選擇分享即時位置的時間長度。" + "您無權在此聊天室分享您的即時位置" diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index 49df684666..9be168e5a2 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -11,6 +11,7 @@ package io.element.android.features.location.impl.share import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter @@ -58,6 +59,7 @@ class DefaultShareLocationEntryPointTest { sessionId = room.sessionId, ), userLocationStateFactory = FakeUserLocationStateFactory(), + remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index f4cc135e9e..6748c5a675 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -19,6 +19,8 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider +import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory @@ -30,6 +32,8 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.features.wellknown.test.aMapTilerConfig +import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -51,6 +55,7 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.assert @@ -60,7 +65,6 @@ import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -77,20 +81,18 @@ class ShareLocationPresenterTest { private val fakeMessageComposerContext = FakeMessageComposerContext() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") - private val fakeMatrixClient = FakeMatrixClient( - sessionId = A_USER_ID, - getMapStyleUrlResult = { Result.success(null) }, - ) + private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) private val durationFormatter = FakeDurationFormatter() - private fun TestScope.createShareLocationPresenter( + private fun createShareLocationPresenter( joinedRoom: JoinedRoom = FakeJoinedRoom(), timelineMode: Timeline.Mode = Timeline.Mode.Live, locationActions: FakeLocationActions = fakeLocationActions, liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), client: FakeMatrixClient = fakeMatrixClient, + remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -104,6 +106,7 @@ class ShareLocationPresenterTest { liveLocationShareManager = liveLocationShareManager, liveLocationStore = liveLocationStore, userLocationStateFactory = FakeUserLocationStateFactory(), + remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider, ) @Test @@ -118,7 +121,7 @@ class ShareLocationPresenterTest { val shareLocationPresenter = createShareLocationPresenter() shareLocationPresenter.test { val state = awaitFirstItem() - assertThat(state.customMapStyleUrl.isLoading()).isFalse() + assertThat(state.customMapTilerConfig.isLoading()).isFalse() assertThat(state.trackUserLocation).isTrue() assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } @@ -126,18 +129,20 @@ class ShareLocationPresenterTest { @Test fun `present - non-null customMapStyleUrl`() = runTest { + val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") val shareLocationPresenter = createShareLocationPresenter( client = FakeMatrixClient( sessionId = A_USER_ID, - getMapStyleUrlResult = { Result.success("aUrl") }, + ), + remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( + getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) } ) ) shareLocationPresenter.test { - skipItems(1) val state = awaitItem() - assertThat(state.customMapStyleUrl.isLoading()).isTrue() + assertThat(state.customMapTilerConfig.isLoading()).isTrue() val finalState = awaitItem() - assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl") + assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) } } @@ -709,7 +714,7 @@ class ShareLocationPresenterTest { } private suspend fun ReceiveTurbine.awaitFirstItem(): T { - skipItems(2) + skipItems(1) return awaitItem() } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index 23b7e9bf4d..69451eedb7 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.location.api.ShowLocationMode @@ -20,7 +21,7 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi import io.element.android.features.location.test.FakeActiveLiveLocationShareManager import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.analytics.test.FakeAnalyticsService @@ -41,8 +42,8 @@ class DefaultShowLocationEntryPointTest { ShowLocationNode( buildContext = buildContext, plugins = plugins, - presenterFactory = object : ShowLocationPresenter.Factory { - override fun create(mode: ShowLocationMode) = ShowLocationPresenter( + presenterFactory = { mode -> + ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { FakePermissionsPresenter() }, locationActions = FakeLocationActions(), @@ -50,9 +51,10 @@ class DefaultShowLocationEntryPointTest { dateFormatter = FakeDateFormatter(), stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, - client = FakeMatrixClient(), + sessionId = A_SESSION_ID, liveLocationShareManager = FakeActiveLiveLocationShareManager(), userLocationStateFactory = FakeUserLocationStateFactory(), + remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index b579a79e4f..77b26eb66c 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState @@ -22,17 +23,20 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.wellknown.test.aMapTilerConfig +import io.element.android.features.wellknown.test.anElementWellKnown import io.element.android.libraries.dateformatter.test.FakeDateFormatter -import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare +import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test @@ -65,8 +69,9 @@ class ShowLocationPresenterTest { ), locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), - client: MatrixClient = FakeMatrixClient(), + sessionId: SessionId = A_SESSION_ID, liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), + remoteEnterpriseConfigProvider: FakeRemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -75,24 +80,26 @@ class ShowLocationPresenterTest { dateFormatter = fakeDateFormatter, stringProvider = FakeStringProvider(), joinedRoom = joinedRoom, - client = client, + sessionId = sessionId, liveLocationShareManager = liveLocationShareManager, userLocationStateFactory = FakeUserLocationStateFactory(), + remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider, ) @Test fun `present - non-null customMapStyleUrl`() = runTest { + val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") val shareLocationPresenter = createShowLocationPresenter( - client = FakeMatrixClient( - sessionId = A_USER_ID, - getMapStyleUrlResult = { Result.success("aUrl") }, + sessionId = A_SESSION_ID, + remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( + getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) } ) ) shareLocationPresenter.test { val state = awaitItem() - assertThat(state.customMapStyleUrl.isLoading()).isTrue() + assertThat(state.customMapTilerConfig.isLoading()).isTrue() val finalState = awaitItem() - assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl") + assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) } } @@ -115,6 +122,8 @@ class ShowLocationPresenterTest { initialState.eventSink(ShowLocationEvent.Share(location)) assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) + + cancelAndIgnoreRemainingEvents() } } @@ -123,10 +132,12 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) val presenter = createShowLocationPresenter( - client = FakeMatrixClient( - getMapStyleUrlResult = { Result.success(null) } + sessionId = A_SESSION_ID, + remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider( + getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = aMapTilerConfig())) } ) ) + presenter.test { val initialState = awaitItem() assertThat(initialState.isTrackMyLocation).isFalse() @@ -281,6 +292,8 @@ class ShowLocationPresenterTest { }.test { val initialState = awaitItem() assertThat(initialState.appName).isEqualTo("app name") + + cancelAndIgnoreRemainingEvents() } } @@ -334,6 +347,8 @@ class ShowLocationPresenterTest { val initialState = awaitItem() assertThat(initialState.locationShares).isEmpty() assertThat(initialState.isSheetDraggable).isFalse() + + cancelAndIgnoreRemainingEvents() } } @@ -408,6 +423,9 @@ class ShowLocationPresenterTest { val initialState = awaitItem() assertThat(initialState.locationShares).isEmpty() + // Wait until we receive the custom map style URL result + skipItems(1) + // Emit a new live share liveSharesFlow.value = listOf( aLiveLocationShare(userId = userId) @@ -447,6 +465,8 @@ class ShowLocationPresenterTest { assertThat(item.avatarData.id).isEqualTo(senderId.value) assertThat(item.avatarData.name).isEqualTo(senderName) assertThat(item.avatarData.url).isEqualTo(avatarUrl) + + cancelAndIgnoreRemainingEvents() } } @@ -456,6 +476,7 @@ class ShowLocationPresenterTest { presenter.test { val state = awaitItem() assertThat(state.isSheetDraggable).isFalse() + cancelAndIgnoreRemainingEvents() } } @@ -465,6 +486,8 @@ class ShowLocationPresenterTest { presenter.test { val state = awaitItem() assertThat(state.hideUserLocationPuck).isFalse() + + cancelAndIgnoreRemainingEvents() } } @@ -476,6 +499,8 @@ class ShowLocationPresenterTest { presenter.test { val state = awaitItem() assertThat(state.hideUserLocationPuck).isFalse() + + cancelAndIgnoreRemainingEvents() } } diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt index bd644e69e9..59c148660e 100644 --- a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt @@ -17,6 +17,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +/** + * Exposes the app lock state: whether a PIN or biometric lock is set up, whether it is mandatory, and whether the app is locked right now. + */ interface LockScreenService { /** * The current lock state of the app. diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt index 74d36b0834..f08d2fc1af 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricAuthenticator.kt @@ -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 } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt index e78a5ee002..d8655e29a3 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt @@ -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, diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt index 79cfc35fbd..b2b7378be4 100644 --- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginIntentResolver.kt @@ -8,6 +8,13 @@ package io.element.android.features.login.api +/** + * Extracts the login parameters from a link, so that a deployment can hand out a URL that pre-fills the sign-in screen. + */ interface LoginIntentResolver { + /** + * @param uriString the link the app was opened with. + * @return the account provider and login hint to pre-fill, or `null` when the link is not a login link. + */ fun parse(uriString: String): LoginParams? } diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt index fc804c14ff..0c35c1655f 100644 --- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/accesscontrol/AccountProviderAccessControl.kt @@ -8,6 +8,14 @@ package io.element.android.features.login.api.accesscontrol +/** + * Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to. + */ interface AccountProviderAccessControl { + /** + * Whether sign-in to this provider is permitted; `true` on a build with no such restriction. + * + * @param accountProviderUrl the server the user is trying to use. + */ suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 7437051e01..bbfe7c47d8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -35,7 +35,6 @@ import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode import io.element.android.features.login.impl.screens.classic.ClassicFlowNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode -import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode import io.element.android.features.preferences.api.PreferencesEntryPoint @@ -131,9 +130,6 @@ class LoginFlowNode( data class LoginPassword( val initialLogin: String = "", ) : NavTarget - - @Parcelize - data class CreateAccount(val url: String) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -155,10 +151,6 @@ class LoginFlowNode( override fun navigateToOAuth(oAuthDetails: OAuthDetails) { navigateToMas(oAuthDetails) } - - override fun navigateToCreateAccount(url: String) { - backstack.push(NavTarget.CreateAccount(url)) - } } createNode(buildContext, listOf(callback)) } @@ -192,10 +184,6 @@ class LoginFlowNode( navigateToMas(oAuthDetails) } - override fun navigateToCreateAccount(url: String) { - backstack.push(NavTarget.CreateAccount(url)) - } - override fun navigateToDeveloperSettings() { backstack.push(NavTarget.AppDeveloperSettings) } @@ -238,10 +226,6 @@ class LoginFlowNode( navigateToMas(oAuthDetails) } - override fun navigateToCreateAccount(url: String) { - backstack.push(NavTarget.CreateAccount(url)) - } - override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword()) } @@ -265,10 +249,6 @@ class LoginFlowNode( navigateToMas(oAuthDetails) } - override fun navigateToCreateAccount(url: String) { - backstack.push(NavTarget.CreateAccount(url)) - } - override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword()) } @@ -281,12 +261,6 @@ class LoginFlowNode( ) createNode(buildContext, plugins = listOf(inputs)) } - is NavTarget.CreateAccount -> { - val inputs = CreateAccountNode.Inputs( - url = navTarget.url, - ) - createNode(buildContext, listOf(inputs)) - } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt index db56047e15..119923c67d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControl.kt @@ -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()) { diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt index 1bb85fb6c9..eccfb6b18f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModePresenter.kt @@ -21,7 +21,6 @@ import io.element.android.features.login.impl.screens.classic.loginwithclassic.L import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter -import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState @@ -40,7 +39,6 @@ import io.element.android.libraries.oauth.api.OAuthActionFlow class LoginModePresenter( private val oAuthActionFlow: OAuthActionFlow, private val authenticationService: MatrixAuthenticationService, - private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever, private val localNetworkPermissionGate: LocalNetworkPermissionGate, private val saveAccountProviderToHistory: SaveAccountProviderToHistory, ) : Presenter { @@ -92,11 +90,9 @@ class LoginModePresenter( authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow() ) } - request.isAccountCreation -> LoginMode.AccountCreation( - webClientUrlForAuthenticationRetriever.retrieve(request.homeserverUrl) - ) + request.isAccountCreation -> throw AccountCreationNotSupported() matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin - else -> error("Unsupported login flow") + else -> error("Unsupported authentication flow") } }.getOrThrow() }.runCatchingUpdatingState( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt index 849ff31f85..86dd550ea6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeState.kt @@ -20,5 +20,4 @@ data class LoginModeState( sealed interface LoginMode { data object PasswordLogin : LoginMode data class OAuth(val oAuthDetails: OAuthDetails) : LoginMode - data class AccountCreation(val url: String) : LoginMode } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt index 3549e17457..8b59db1d61 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/login/LoginModeView.kt @@ -34,7 +34,6 @@ fun LoginModeView( onLearnMoreClick: () -> Unit, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, - onCreateAccountContinue: (url: String) -> Unit ) { val context = LocalContext.current when (loginMode) { @@ -120,7 +119,6 @@ fun LoginModeView( when (val loginModeData = loginMode.data) { is LoginMode.OAuth -> onOAuthDetails(loginModeData.oAuthDetails) LoginMode.PasswordLogin -> onNeedLoginPassword() - is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url) } // Also clear the data, to let the next screen be able to go back onClearError() @@ -139,7 +137,6 @@ internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider:: onLearnMoreClick = {}, onOAuthDetails = {}, onNeedLoginPassword = {}, - onCreateAccountContinue = {} ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt index 5f79f197d9..6432042818 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderNode.kt @@ -32,7 +32,6 @@ class ChooseAccountProviderNode( interface Callback : Plugin { fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToCreateAccount(url: String) } private val callback: Callback = callback() @@ -48,7 +47,6 @@ class ChooseAccountProviderNode( onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, - onCreateAccountContinue = callback::navigateToCreateAccount, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt index e858789bb4..0d9d8cfba7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderView.kt @@ -57,7 +57,6 @@ fun ChooseAccountProviderView( onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, - onCreateAccountContinue: (url: String) -> Unit, modifier: Modifier = Modifier, ) { val isLoading by remember(state.loginModeState.loginMode) { @@ -137,7 +136,6 @@ fun ChooseAccountProviderView( onLearnMoreClick = onLearnMoreClick, onOAuthDetails = onOAuthDetails, onNeedLoginPassword = onNeedLoginPassword, - onCreateAccountContinue = onCreateAccountContinue, ) } } @@ -161,6 +159,5 @@ internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountPro onLearnMoreClick = { }, onOAuthDetails = { }, onNeedLoginPassword = { }, - onCreateAccountContinue = { }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt index cfbd86f363..160b500cf6 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/ClassicFlowNode.kt @@ -55,7 +55,6 @@ class ClassicFlowNode( fun navigateToOnBoarding(allowBackNavigation: Boolean) fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToCreateAccount(url: String) } sealed interface NavTarget : Parcelable { @@ -115,10 +114,6 @@ class ClassicFlowNode( callback.navigateToOAuth(oAuthDetails) } - override fun navigateToCreateAccount(url: String) { - callback.navigateToCreateAccount(url) - } - override fun navigateToMissingKeyBackup() { backstack.push(NavTarget.MissingKeyBackup) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt index d5acca38ae..ff56b0e8e8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicNode.kt @@ -36,7 +36,6 @@ class LoginWithClassicNode( fun navigateToOtherOptions() fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToCreateAccount(url: String) fun navigateToMissingKeyBackup() } @@ -63,7 +62,6 @@ class LoginWithClassicNode( onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, - onCreateAccountContinue = callback::navigateToCreateAccount, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt index 75181a068e..81638b4be0 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/classic/loginwithclassic/LoginWithClassicView.kt @@ -64,7 +64,6 @@ fun LoginWithClassicView( onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, - onCreateAccountContinue: (url: String) -> Unit, modifier: Modifier = Modifier, ) { val isLoading by remember(state.loginModeState.loginMode) { @@ -204,7 +203,6 @@ fun LoginWithClassicView( onLearnMoreClick = onLearnMoreClick, onOAuthDetails = onOAuthDetails, onNeedLoginPassword = onNeedLoginPassword, - onCreateAccountContinue = onCreateAccountContinue, ) LocalNetworkPermissionDialogView( dialog = state.loginModeState.localNetworkPermissionDialog, @@ -226,6 +224,5 @@ internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicState onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, - onCreateAccountContinue = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index 417b0d4850..e1a2d85412 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -45,7 +45,6 @@ class ConfirmAccountProviderNode( interface Callback : Plugin { fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToCreateAccount(url: String) } private val callback: Callback = callback() @@ -59,7 +58,6 @@ class ConfirmAccountProviderNode( modifier = modifier, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, - onCreateAccountContinue = callback::navigateToCreateAccount, onLearnMoreClick = { openLearnMorePage(context) }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index 385a8477f6..9cd288bfb1 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -73,7 +73,6 @@ fun ConfirmAccountProviderView( onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, - onCreateAccountContinue: (url: String) -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -207,7 +206,6 @@ fun ConfirmAccountProviderView( onLearnMoreClick = onLearnMoreClick, onOAuthDetails = onOAuthDetails, onNeedLoginPassword = onNeedLoginPassword, - onCreateAccountContinue = onCreateAccountContinue, ) LocalNetworkPermissionDialogView( @@ -253,7 +251,6 @@ internal fun ConfirmAccountProviderViewPreview( state = state, onOAuthDetails = {}, onNeedLoginPassword = {}, - onCreateAccountContinue = {}, onLearnMoreClick = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt deleted file mode 100644 index a51557347e..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountEvents.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -sealed interface CreateAccountEvents { - data class SetPageProgress(val progress: Int) : CreateAccountEvents - data class OnMessageReceived(val message: String) : CreateAccountEvents -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt deleted file mode 100644 index e1972e5a0f..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountNode.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import android.app.Activity -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.bumble.appyx.core.modality.BuildContext -import com.bumble.appyx.core.node.Node -import com.bumble.appyx.core.plugin.Plugin -import dev.zacsweers.metro.AppScope -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.libraries.androidutils.browser.openUrlInChromeCustomTab -import io.element.android.libraries.architecture.NodeInputs -import io.element.android.libraries.architecture.inputs - -@ContributesNode(AppScope::class) -@AssistedInject -class CreateAccountNode( - @Assisted buildContext: BuildContext, - @Assisted plugins: List, - presenterFactory: CreateAccountPresenter.Factory, -) : Node(buildContext, plugins = plugins) { - data class Inputs( - val url: String, - ) : NodeInputs - - private val presenter = presenterFactory.create(inputs().url) - - private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) { - activity.openUrlInChromeCustomTab(null, darkTheme, url) - } - - @Composable - override fun View(modifier: Modifier) { - val activity = requireNotNull(LocalActivity.current) - val isDark = ElementTheme.isLightTheme.not() - val state = presenter.present() - CreateAccountView( - state = state, - modifier = modifier, - onBackClick = ::navigateUp, - onOpenExternalUrl = { - onOpenExternalUrl(activity, isDark, it) - }, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt deleted file mode 100644 index d750586e94..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.screens.createaccount - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import dev.zacsweers.metro.Assisted -import dev.zacsweers.metro.AssistedFactory -import dev.zacsweers.metro.AssistedInject -import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.extensions.flatMap -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.matrix.api.core.SessionId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -@AssistedInject -class CreateAccountPresenter( - @Assisted private val url: String, - private val authenticationService: MatrixAuthenticationService, - private val messageParser: MessageParser, - private val buildMeta: BuildMeta, - private val saveAccountProviderToHistory: SaveAccountProviderToHistory, -) : Presenter { - @AssistedFactory - interface Factory { - fun create(url: String): CreateAccountPresenter - } - - @Composable - override fun present(): CreateAccountState { - val coroutineScope = rememberCoroutineScope() - val pageProgress: MutableState = remember { mutableIntStateOf(0) } - val createAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } - - fun handleEvent(event: CreateAccountEvents) { - when (event) { - is CreateAccountEvents.SetPageProgress -> { - pageProgress.value = event.progress - } - is CreateAccountEvents.OnMessageReceived -> { - // Ignore unexpected message - if (event.message.contains("isTrusted")) return - coroutineScope.importSession(event.message, createAction) - } - } - } - - return CreateAccountState( - url = url, - pageProgress = pageProgress.value, - isDebugBuild = buildMeta.isDebuggable, - createAction = createAction.value, - eventSink = ::handleEvent, - ) - } - - private fun CoroutineScope.importSession(message: String, loggedInState: MutableState>) = launch { - loggedInState.value = AsyncAction.Loading - runCatchingExceptions { - messageParser.parse(message) - }.flatMap { externalSession -> - authenticationService.importCreatedSession(externalSession) - }.onSuccess { sessionId -> - saveAccountProviderToHistory() - loggedInState.value = AsyncAction.Success(sessionId) - }.onFailure { failure -> - loggedInState.value = AsyncAction.Failure(failure) - } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt deleted file mode 100644 index de7efe573e..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountState.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.SessionId - -data class CreateAccountState( - val url: String, - val pageProgress: Int, - val createAction: AsyncAction, - val isDebugBuild: Boolean, - val eventSink: (CreateAccountEvents) -> Unit -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt deleted file mode 100644 index 976ceae70d..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountStateProvider.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.screens.createaccount - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.SessionId - -open class CreateAccountStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aCreateAccountState(), - aCreateAccountState(pageProgress = 33), - aCreateAccountState(createAction = AsyncAction.Loading), - aCreateAccountState(createAction = AsyncAction.Failure(RuntimeException("Failed to create account"))), - ) -} - -private fun aCreateAccountState( - pageProgress: Int = 100, - createAction: AsyncAction = AsyncAction.Uninitialized, -) = CreateAccountState( - url = "https://example.com", - isDebugBuild = true, - pageProgress = pageProgress, - createAction = createAction, - eventSink = {} -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt deleted file mode 100644 index 051188627a..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountView.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import android.annotation.SuppressLint -import android.view.ViewGroup -import android.webkit.JsResult -import android.webkit.WebChromeClient -import android.webkit.WebView -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import io.element.android.compound.theme.ElementTheme -import io.element.android.features.login.impl.R -import io.element.android.libraries.designsystem.components.async.AsyncActionView -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor -import timber.log.Timber - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CreateAccountView( - state: CreateAccountState, - onBackClick: () -> Unit, - onOpenExternalUrl: (String) -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - titleStr = stringResource(R.string.screen_create_account_title), - navigationIcon = { - BackButton(onClick = onBackClick) - }, - ) - } - ) { contentPadding -> - Box( - modifier = Modifier - .padding(contentPadding) - .consumeWindowInsets(contentPadding) - .fillMaxSize() - ) { - CreateAccountWebView( - modifier = Modifier - .fillMaxSize(), - state = state, - onWebViewCreate = { webView -> - WebViewMessageInterceptor( - webView, - state.isDebugBuild, - onOpenExternalUrl = onOpenExternalUrl, - onMessage = { - state.eventSink(CreateAccountEvents.OnMessageReceived(it)) - }, - ) - } - ) - AnimatedVisibility( - visible = state.pageProgress != 100, - // Disable enter animation - enter = fadeIn(initialAlpha = 1f), - exit = fadeOut(), - ) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(2.dp), - progress = { state.pageProgress / 100f }, - trackColor = ElementTheme.colors.progressIndicatorTrackColor, - ) - } - } - } - - AsyncActionView( - async = state.createAction, - onSuccess = {}, - onErrorDismiss = onBackClick, - onRetry = null - ) -} - -@Composable -private fun CreateAccountWebView( - state: CreateAccountState, - onWebViewCreate: (WebView) -> Unit, - modifier: Modifier = Modifier, -) { - if (LocalInspectionMode.current) { - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Text("WebView - can't be previewed") - } - } else { - AndroidView( - modifier = modifier, - factory = { context -> - WebView(context).apply { - onWebViewCreate(this) - setup(state) - } - }, - update = { webView -> - if (webView.url != state.url) { - webView.loadUrl(state.url) - } - }, - onRelease = { webView -> - webView.destroy() - } - ) - } -} - -@SuppressLint("SetJavaScriptEnabled") -private fun WebView.setup(state: CreateAccountState) { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - with(settings) { - javaScriptEnabled = true - domStorageEnabled = true - } - - webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView?, newProgress: Int) { - super.onProgressChanged(view, newProgress) - state.eventSink(CreateAccountEvents.SetPageProgress(newProgress)) - } - - override fun onJsBeforeUnload(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { - Timber.w("onJsBeforeUnload, cancelling the dialog, we will open external links in a Custom Chrome Tab") - result?.confirm() - return true - } - } -} - -@PreviewsDayNight -@Composable -internal fun CreateAccountViewPreview(@PreviewParameter(CreateAccountStateProvider::class) state: CreateAccountState) = ElementPreview { - CreateAccountView( - state = state, - onBackClick = {}, - onOpenExternalUrl = {}, - ) -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt deleted file mode 100644 index d7954373bc..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MessageParser.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.matrix.api.auth.external.ExternalSession - -interface MessageParser { - /** - * Parse the message and return the ExternalSession object, or - * throw an exception if the message is invalid. - */ - fun parse(message: String): ExternalSession -} - -@ContributesBinding(AppScope::class) -class DefaultMessageParser( - private val accountProviderDataSource: AccountProviderDataSource, - private val json: JsonProvider, -) : MessageParser { - override fun parse(message: String): ExternalSession { - val response = json().decodeFromString(MobileRegistrationResponse.serializer(), message) - val userId = response.userId ?: error("No user ID in response") - val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url - val accessToken = response.accessToken ?: error("No access token in response") - val deviceId = response.deviceId ?: error("No device ID in response") - return ExternalSession( - userId = userId, - homeserverUrl = homeServer, - accessToken = accessToken, - deviceId = deviceId, - refreshToken = null, - ) - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt deleted file mode 100644 index 5e78b1bd37..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/MobileRegistrationResponse.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * For ref: - * https://github.com/element-hq/matrix-react-sdk/pull/42/files#diff-2bbba5a742004fd4e924a639ded444279f66f7ad890cb669fbc91ac6b8638c64R56 - */ -@Serializable -data class MobileRegistrationResponse( - @SerialName("user_id") - val userId: String? = null, - @SerialName("home_server") - val homeServer: String? = null, - @SerialName("access_token") - val accessToken: String? = null, - @SerialName("device_id") - val deviceId: String? = null, -) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt deleted file mode 100644 index 20d503522b..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/WebViewMessageInterceptor.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.screens.createaccount - -import android.graphics.Bitmap -import android.webkit.JavascriptInterface -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewFeature - -class WebViewMessageInterceptor( - webView: WebView, - private val debugLog: Boolean, - private val onOpenExternalUrl: (String) -> Unit, - private val onMessage: (String) -> Unit, -) { - companion object { - // We call both the WebMessageListener and the JavascriptInterface objects in JS with this - // 'listenerName' so they can both receive the data from the WebView when - // `${LISTENER_NAME}.postMessage(...)` is called - const val LISTENER_NAME = "elementX" - } - - init { - webView.webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - super.onPageStarted(view, url, favicon) - - // We inject this JS code when the page starts loading to attach a message listener to the window. - view?.evaluateJavascript( - """ - window.addEventListener( - "mobileregistrationresponse", - (event) => { - let json = JSON.stringify(event.detail) - ${"console.log('message sent: ' + json);".takeIf { debugLog }} - $LISTENER_NAME.postMessage(json); - }, - false, - ); - """.trimIndent(), - null - ) - } - - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - request ?: return false - // Load the URL in a Chrome Custom Tab, and return true to cancel the load - onOpenExternalUrl(request.url.toString()) - return true - } - } - - // Use WebMessageListener if supported, otherwise use JavascriptInterface - if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { - // Create a WebMessageListener, which will receive messages from the WebView and reply to them - val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ -> - onMessageReceived(message.data) - } - WebViewCompat.addWebMessageListener( - webView, - LISTENER_NAME, - setOf("*"), - webMessageListener - ) - } else { - webView.addJavascriptInterface( - object { - @JavascriptInterface - fun postMessage(json: String?) { - onMessageReceived(json) - } - }, - LISTENER_NAME, - ) - } - } - - private fun onMessageReceived(json: String?) { - // Here is where we would handle the messages from the WebView, passing them to the listener - json?.let { onMessage(it) } - } -} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 99f7e86fd3..244e684c5b 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -41,7 +41,6 @@ class OnBoardingNode( fun navigateToBugReport() fun navigateToLoginPassword() fun navigateToOAuth(oAuthDetails: OAuthDetails) - fun navigateToCreateAccount(url: String) fun navigateToDeveloperSettings() fun onDone() } @@ -74,7 +73,6 @@ class OnBoardingNode( onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, - onCreateAccountContinue = callback::navigateToCreateAccount, onBackClick = callback::onDone, onDeveloperSettingsClick = callback::navigateToDeveloperSettings, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index bf96b2dbe0..10fe3a854c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -71,7 +71,6 @@ fun OnBoardingView( onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, - onCreateAccountContinue: (url: String) -> Unit, onReportProblem: () -> Unit, modifier: Modifier = Modifier, ) { @@ -84,7 +83,6 @@ fun OnBoardingView( onLearnMoreClick = onLearnMoreClick, onOAuthDetails = onOAuthDetails, onNeedLoginPassword = onNeedLoginPassword, - onCreateAccountContinue = onCreateAccountContinue, ) LocalNetworkPermissionDialogView( dialog = state.loginModeState.localNetworkPermissionDialog, @@ -366,6 +364,5 @@ internal fun OnBoardingViewPreview( onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, - onCreateAccountContinue = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt deleted file mode 100644 index df08cf468b..0000000000 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/web/WebClientUrlForAuthenticationRetriever.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.web - -import androidx.core.net.toUri -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.wellknown.api.WellknownRetriever -import timber.log.Timber - -interface WebClientUrlForAuthenticationRetriever { - suspend fun retrieve(homeServerUrl: String): String -} - -@ContributesBinding(AppScope::class) -class DefaultWebClientUrlForAuthenticationRetriever( - private val wellknownRetriever: WellknownRetriever, -) : 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() - } - } -} diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index 89e4412d0f..4f04c87d34 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -37,7 +37,14 @@ "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation." "Willkommen zurück!" "Anmelden bei %1$s" + "Element Classic öffnen" + "Öffne Element Classic auf deinem Gerät" + "Geh zu Einstellungen > Sicherheit & Datenschutz" + "Folge den Anweisungen, um den Schlüsselspeicher zu aktivieren." + "Zurück zu %1$s" + "Aktiviere deinen Schlüsselspeicher, bevor du mit %1$s fortfährst." "Version %1$s" + "Konto wird geprüft" "Manuell anmelden" "Anmelden bei %1$s" "Mit QR-Code anmelden" diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml index 396f2025b7..fe2d443ce3 100644 --- a/features/login/impl/src/main/res/values-sv/translations.xml +++ b/features/login/impl/src/main/res/values-sv/translations.xml @@ -28,7 +28,7 @@ "Vad är adressen till din server?" "Välj din server" "Skapa konto" - "Detta konto har avaktiverats." + "Detta konto har raderats." "Felaktigt användarnamn och/eller lösenord" "Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'" "Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning." diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml index c4d16f5928..76477eb36e 100644 --- a/features/login/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml @@ -11,7 +11,7 @@ "您即將在 %s 建立帳號" "Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。" "其他" - "使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。" + "使用不同的帳號提供者,例如您自己的伺服器或工作帳號。" "更改帳號提供者" "Google Play" "%1$s 需要 Element Pro 應用程式。請從商店下載。" @@ -28,7 +28,7 @@ "您的伺服器地址?" "選擇您的伺服器" "建立帳號" - "這個帳號已被停用。" + "此帳號已被刪除。" "不正確的使用者名稱或密碼" "此非有效的使用者識別字串。預期的格式:‘@user:homeserver.org’" "此伺服器已設定為使用重新整理權杖。使用以密碼為基礎的登入方式時,不支援這些功能。" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt index e3cf1c0ea7..ee4cea0532 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/accesscontrol/DefaultAccountProviderAccessControlTest.kt @@ -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() { diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index 868f56f8a7..175b7e3057 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -18,18 +18,19 @@ import io.element.android.features.login.impl.accountprovider.anAccountProviderD 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 @@ -159,7 +160,8 @@ class ChangeServerPresenterTest { @Test fun `present - change server element pro required error`() = runTest { - val getElementWellKnownResult = lambdaRecorder> { + val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource -> + assertThat(homeserver).isEqualTo(A_HOMESERVER_URL) WellknownRetrieverResult.Success( anElementWellKnown( enforceElementPro = true, @@ -175,8 +177,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 @@ -184,9 +184,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() } } @@ -270,7 +268,8 @@ class ChangeServerPresenterTest { accountProviderDataSource = accountProviderDataSource, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = enterpriseService, - wellknownRetriever = wellknownRetriever, + wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever), + temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), ), localNetworkPermissionGate = LocalNetworkPermissionGate( advisor = localNetworkPermissionAdvisor, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt index f1ce703d1c..cd64638563 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/chooseaccountprovider/ChooseAccountProviderViewTest.kt @@ -84,7 +84,6 @@ class ChooseAccountProviderViewTest : RobolectricTest() { onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(), onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), onLearnMoreClick: () -> Unit = EnsureNeverCalled(), - onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { ChooseAccountProviderView( @@ -93,7 +92,6 @@ class ChooseAccountProviderViewTest : RobolectricTest() { onOAuthDetails = onOAuthDetails, onNeedLoginPassword = onNeedLoginPassword, onLearnMoreClick = onLearnMoreClick, - onCreateAccountContinue = onCreateAccountContinue, ) } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index b3bf502965..687be70c7c 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -20,12 +20,11 @@ import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissio import io.element.android.features.login.impl.login.LoginMode import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported import io.element.android.features.login.impl.screens.onboarding.createLoginModePresenter -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.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.AN_EXCEPTION +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.oauth.api.OAuthAction @@ -264,7 +263,7 @@ class ConfirmAccountProviderPresenterTest { } @Test - fun `present - confirm account creation without oidc and without url generates an error`() = runTest { + fun `present - confirm account creation without oidc generates an error`() = runTest { val authenticationService = FakeMatrixAuthenticationService( setHomeserverResult = { Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) @@ -273,9 +272,6 @@ class ConfirmAccountProviderPresenterTest { val presenter = createConfirmAccountProviderPresenter( params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), matrixAuthenticationService = authenticationService, - webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { - throw AccountCreationNotSupported() - }, ) presenter.test { val initialState = awaitItem() @@ -311,50 +307,6 @@ class ConfirmAccountProviderPresenterTest { } } - @Test - fun `present - confirm account creation with OAuth and url continues with OAuth`() = runTest { - val aUrl = "aUrl" - val authenticationService = FakeMatrixAuthenticationService( - setHomeserverResult = { - Result.success(aMatrixHomeServerDetails(supportsOAuthLogin = true)) - }, - ) - val presenter = createConfirmAccountProviderPresenter( - params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), - matrixAuthenticationService = authenticationService, - webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl }, - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) - val submittedState = awaitLoginMode { it is AsyncData.Success } - assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isInstanceOf(LoginMode.OAuth::class.java) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - confirm account creation without OAuth and with url continuing with url`() = runTest { - val aUrl = "aUrl" - val authenticationService = FakeMatrixAuthenticationService( - setHomeserverResult = { - Result.success(aMatrixHomeServerDetails(supportsPasswordLogin = true)) - }, - ) - val presenter = createConfirmAccountProviderPresenter( - params = ConfirmAccountProviderPresenter.Params(isAccountCreation = true), - matrixAuthenticationService = authenticationService, - webClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever { aUrl }, - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(ConfirmAccountProviderEvents.Continue(AuthenticationConfig.MATRIX_ORG_URL)) - val submittedState = awaitLoginMode { it is AsyncData.Success } - assertThat(submittedState.loginModeState.loginMode.dataOrNull()).isEqualTo(LoginMode.AccountCreation(aUrl)) - cancelAndIgnoreRemainingEvents() - } - } - @Test fun `present - offers an autocomplete suggestion from the account provider history`() = runTest { val presenter = createConfirmAccountProviderPresenter( @@ -419,7 +371,6 @@ class ConfirmAccountProviderPresenterTest { accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), - webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever = FakeWebClientUrlForAuthenticationRetriever(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), ) = ConfirmAccountProviderPresenter( params = params, @@ -428,7 +379,6 @@ class ConfirmAccountProviderPresenterTest { loginModePresenter = createLoginModePresenter( authenticationService = matrixAuthenticationService, oAuthActionFlow = defaultOAuthActionFlow, - webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever, ), changeServerPresenter = ChangeServerPresenter( authenticationService = matrixAuthenticationService, @@ -437,7 +387,8 @@ class ConfirmAccountProviderPresenterTest { enterpriseService = FakeEnterpriseService( isAllowedToConnectToHomeserverResult = { true }, ), - wellknownRetriever = FakeWellknownRetriever(), + wellknownRetrieverFactory = FakeWellknownRetrieverFactory(), + temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), ), localNetworkPermissionGate = LocalNetworkPermissionGate( advisor = FakeLocalNetworkPermissionAdvisor(), diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt deleted file mode 100644 index d2c9cf32b2..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource -import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory -import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.matrix.api.auth.external.ExternalSession -import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus -import io.element.android.libraries.matrix.test.AN_EXCEPTION -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService -import io.element.android.libraries.matrix.test.core.aBuildMeta -import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService -import io.element.android.libraries.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore -import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value -import io.element.android.tests.testutils.test -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class CreateAccountPresenterTest { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = createPresenter() - presenter.test { - val initialState = awaitItem() - assertThat(initialState.url).isEqualTo("aUrl") - assertThat(initialState.pageProgress).isEqualTo(0) - assertThat(initialState.createAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(initialState.isDebugBuild).isTrue() - } - } - - @Test - fun `present - set up progress update the state`() = runTest { - val presenter = createPresenter() - presenter.test { - val initialState = awaitItem() - initialState.eventSink(CreateAccountEvents.SetPageProgress(33)) - assertThat(awaitItem().pageProgress).isEqualTo(33) - } - } - - @Test - fun `present - receiving a message not able to be parsed change the state to error`() = runTest { - val presenter = createPresenter( - messageParser = FakeMessageParser { error("An error") } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(CreateAccountEvents.OnMessageReceived("")) - assertThat(awaitItem().createAction).isInstanceOf(AsyncAction.Failure::class.java) - } - } - - @Test - fun `present - receiving a message containing isTrusted is ignored`() = runTest { - val presenter = createPresenter() - presenter.test { - val initialState = awaitItem() - initialState.eventSink(CreateAccountEvents.OnMessageReceived("isTrusted")) - } - } - - @Test - fun `present - receiving a message able to be parsed change the state to success`() = runTest { - val lambda = lambdaRecorder { _ -> anExternalSession() } - val sessionVerificationService = FakeSessionVerificationService() - val presenter = createPresenter( - authenticationService = FakeMatrixAuthenticationService( - importCreatedSessionLambda = { Result.success(A_SESSION_ID) } - ), - messageParser = FakeMessageParser(lambda), - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage")) - assertThat(awaitItem().createAction.isLoading()).isTrue() - sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) - assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID) - } - lambda.assertions().isCalledOnce().with(value("aMessage")) - } - - @Test - fun `present - successful account creation saves the account provider to history`() = runTest { - val appPreferencesStore = InMemoryAppPreferencesStore() - val presenter = createPresenter( - authenticationService = FakeMatrixAuthenticationService( - importCreatedSessionLambda = { Result.success(A_SESSION_ID) } - ), - messageParser = FakeMessageParser { anExternalSession() }, - appPreferencesStore = appPreferencesStore, - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(CreateAccountEvents.OnMessageReceived("aMessage")) - assertThat(awaitItem().createAction.isLoading()).isTrue() - assertThat(awaitItem().createAction.dataOrNull()).isEqualTo(A_SESSION_ID) - assertThat(appPreferencesStore.getHomeserverHistoryFlow().first()) - .containsExactly(AuthenticationConfig.MATRIX_ORG_URL) - } - } - - @Test - fun `present - receiving a message able to be parsed but error in importing change the state to error`() = runTest { - val presenter = createPresenter( - authenticationService = FakeMatrixAuthenticationService( - importCreatedSessionLambda = { Result.failure(AN_EXCEPTION) } - ), - messageParser = FakeMessageParser { anExternalSession() } - ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(CreateAccountEvents.OnMessageReceived("")) - assertThat(awaitItem().createAction.isLoading()).isTrue() - assertThat(awaitItem().createAction.errorOrNull()).isNotNull() - } - } - - private fun createPresenter( - url: String = "aUrl", - authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), - messageParser: MessageParser = FakeMessageParser(), - buildMeta: BuildMeta = aBuildMeta(), - appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), - accountProviderDataSource: AccountProviderDataSource = anAccountProviderDataSource(), - ) = CreateAccountPresenter( - url = url, - authenticationService = authenticationService, - messageParser = messageParser, - buildMeta = buildMeta, - saveAccountProviderToHistory = SaveAccountProviderToHistory(accountProviderDataSource, appPreferencesStore), - ) -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt deleted file mode 100644 index e3fea27557..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/DefaultMessageParserTest.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import com.google.common.truth.Truth.assertThat -import io.element.android.appconfig.AuthenticationConfig -import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.matrix.api.auth.external.ExternalSession -import kotlinx.serialization.SerializationException -import org.junit.Assert.assertThrows -import org.junit.Test - -class DefaultMessageParserTest { - private val validMessage = """ - { - "user_id": "user_id", - "home_server": "home_server", - "access_token": "access_token", - "device_id": "device_id" - } - """.trimIndent() - - @Test - fun `DefaultMessageParser is able to parse correct message`() { - val sut = createDefaultMessageParser() - assertThat(sut.parse(validMessage)).isEqualTo( - anExternalSession( - homeserverUrl = "home_server", - ) - ) - } - - @Test - fun `DefaultMessageParser should throw Exception in case of error`() { - val sut = createDefaultMessageParser() - // kotlinx.serialization.json.internal.JsonDecodingException - assertThrows(SerializationException::class.java) { sut.parse("invalid json") } - // missing userId - assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""user_id": "user_id",""", "")) } - // missing accessToken - assertThrows(IllegalStateException::class.java) { sut.parse(validMessage.replace(""""access_token": "access_token",""", "")) } - // missing deviceId - assertThrows(IllegalStateException::class.java) { - sut.parse( - validMessage - .replace(""""access_token": "access_token",""", """"access_token": "access_token"""") - .replace(""""device_id": "device_id"""", "") - ) - } - } - - @Test - fun `DefaultMessageParser should be successful even is homeserver url is missing`() { - val sut = createDefaultMessageParser() - // missing homeServer - assertThat(sut.parse(validMessage.replace(""""home_server": "home_server",""", ""))).isEqualTo( - anExternalSession( - homeserverUrl = AuthenticationConfig.MATRIX_ORG_URL, - ) - ) - } - - private fun createDefaultMessageParser(): DefaultMessageParser { - return DefaultMessageParser( - accountProviderDataSource = anAccountProviderDataSource(), - json = DefaultJsonProvider(), - ) - } -} - -internal fun anExternalSession( - homeserverUrl: String = "home_server", -) = ExternalSession( - userId = "user_id", - homeserverUrl = homeserverUrl, - accessToken = "access_token", - deviceId = "device_id", - refreshToken = null, -) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt deleted file mode 100644 index 62d6dd7dd4..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/FakeMessageParser.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.screens.createaccount - -import io.element.android.libraries.matrix.api.auth.external.ExternalSession -import io.element.android.tests.testutils.lambda.lambdaError - -class FakeMessageParser( - private val parseResult: (String) -> ExternalSession = { lambdaError() } -) : MessageParser { - override fun parse(message: String): ExternalSession { - return parseResult(message) - } -} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 49ea0e3caa..c8b9fe836e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -19,9 +19,8 @@ import io.element.android.features.login.impl.accountprovider.SaveAccountProvide import io.element.android.features.login.impl.accountprovider.anAccountProviderDataSource import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate 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 @@ -32,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.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -307,7 +310,8 @@ private fun createPresenter( enterpriseService = enterpriseService, defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl( enterpriseService = enterpriseService, - wellknownRetriever = wellknownRetriever, + wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever), + temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(), ), rageshakeFeatureAvailability = rageshakeFeatureAvailability, loginModePresenter = loginModePresenter, @@ -319,17 +323,15 @@ private fun createPresenter( fun createLoginModePresenter( oAuthActionFlow: OAuthActionFlow = FakeOAuthActionFlow(), 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(), saveAccountProviderToHistory: SaveAccountProviderToHistory = SaveAccountProviderToHistory(anAccountProviderDataSource(), InMemoryAppPreferencesStore()), ): LoginModePresenter = LoginModePresenter( oAuthActionFlow = oAuthActionFlow, authenticationService = authenticationService, - webClientUrlForAuthenticationRetriever = webClientUrlForAuthenticationRetriever, localNetworkPermissionGate = LocalNetworkPermissionGate( advisor = localNetworkPermissionAdvisor, permissionsPresenterFactory = permissionsPresenterFactory, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt index 1bdcb9bda1..dfa4119711 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnboardingViewTest.kt @@ -234,22 +234,6 @@ class OnboardingViewTest : RobolectricTestParameter() { eventSink.assertSingle(OnBoardingEvents.ClearError) } - @Test - fun `when success AccountCreation - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest { - val eventSink = EventsRecorder() - val oAuthDetails = OAuthDetails("aUrl") - ensureCalledOnceWithParam(oAuthDetails.url) { callback -> - setOnboardingView( - state = anOnBoardingState( - loginModeState = aLoginModeState(loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl"))), - eventSink = eventSink, - ), - onCreateAccountContinue = callback, - ) - } - eventSink.assertSingle(OnBoardingEvents.ClearError) - } - private fun AndroidComposeUiTest.setOnboardingView( state: OnBoardingState, onBackClick: () -> Unit = EnsureNeverCalled(), @@ -261,7 +245,6 @@ class OnboardingViewTest : RobolectricTestParameter() { onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(), onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), onLearnMoreClick: () -> Unit = EnsureNeverCalled(), - onCreateAccountContinue: (url: String) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { OnBoardingView( @@ -275,7 +258,6 @@ class OnboardingViewTest : RobolectricTestParameter() { onOAuthDetails = onOAuthDetails, onNeedLoginPassword = onNeedLoginPassword, onLearnMoreClick = onLearnMoreClick, - onCreateAccountContinue = onCreateAccountContinue, ) } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt deleted file mode 100644 index f52af0adf3..0000000000 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/web/FakeWebClientUrlForAuthenticationRetriever.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector 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.web - -import io.element.android.tests.testutils.lambda.lambdaError - -class FakeWebClientUrlForAuthenticationRetriever( - private val retrieveLambda: suspend (homeServerUrl: String) -> String = { lambdaError() } -) : WebClientUrlForAuthenticationRetriever { - override suspend fun retrieve(homeServerUrl: String): String { - return retrieveLambda(homeServerUrl) - } -} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt index 7841b85a5a..affd2be94d 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt @@ -10,7 +10,17 @@ package io.element.android.features.logout.api.direct import androidx.compose.runtime.Composable +/** + * Renders the confirmation dialogs of logging out directly, in particular the warning shown when the user would lose their room keys. + * + * It draws nothing until the host screen sends a logout event. + */ fun interface DirectLogoutView { + /** + * Draws whichever dialog the current state calls for. + * + * @param state the state produced by the direct logout presenter. + */ @Composable fun Render(state: DirectLogoutState) } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt index 534af8a5f9..12af1b32f2 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt @@ -17,5 +17,6 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode * the composer is in a thread, if it's editing a message, etc. */ interface MessageComposerContext { + /** What the composer is currently doing: writing a new message, editing one, replying, or composing in a thread. */ val composerMode: MessageComposerMode } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index 3eecd54f3e..dd49dd0465 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -50,7 +50,16 @@ interface MessagesEntryPoint : FeatureEntryPoint { callback: Callback, ): Node + /** + * Lets callers outside this feature drive an already created messages node, for navigation that has to happen after it is attached. + */ interface NodeProxy { + /** + * Opens a thread on top of the messages screen, suspending until the thread node is attached. + * + * @param threadId the thread to open. + * @param focusedEventId the event to scroll to within the thread, or `null` to open at the latest message. + */ suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) } } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt index 849b97479e..550373ed00 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/pinned/PinnedEventsTimelineProvider.kt @@ -10,4 +10,9 @@ package io.element.android.features.messages.api.pinned import io.element.android.libraries.matrix.api.timeline.TimelineProvider +/** + * A [TimelineProvider] whose active timeline holds the pinned events of the room, rather than its live events. + * + * It exists as its own type so that it can be injected where only the pinned timeline is wanted. + */ interface PinnedEventsTimelineProvider : TimelineProvider diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt index f44c996cac..baeb37e417 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt @@ -11,9 +11,16 @@ package io.element.android.features.messages.api.timeline import androidx.compose.runtime.Composable import io.element.android.wysiwyg.utils.HtmlConverter +/** + * Provides the converter that turns message HTML into styled text. + * + * The converter depends on the current theme and typography, so [Update] must be composed before [provide] is called. + */ interface HtmlConverterProvider { + /** Rebuilds the converter from the current Compose theme; call this once, high in the composition of a screen that renders messages. */ @Composable fun Update() + /** Returns the converter built by the last [Update] call. */ fun provide(): HtmlConverter } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt index 848f9fe82d..832d029e40 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -12,7 +12,13 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.timeline.Timeline fun interface VoiceMessageComposerPresenter : Presenter { + /** + * Creates a presenter for one voice message composer. + */ interface Factory { + /** + * @param timelineMode the timeline the recording will be sent to, which matters for threads in particular. + */ fun create(timelineMode: Timeline.Mode): VoiceMessageComposerPresenter } } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index b40aebab39..0b51a2b80d 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(projects.libraries.push.api) implementation(projects.libraries.uiUtils) implementation(projects.libraries.testtags) + implementation(projects.libraries.wellknown.api) implementation(projects.features.networkmonitor.api) implementation(projects.features.contentscanner.api) implementation(projects.services.analytics.compose) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index b09c7e4ce7..669a696111 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 4a14c11f4b..077d086720 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt index d5fb0a100b..e7006b5aef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -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)) }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 18252ee17d..5f12bf4be1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -425,6 +425,7 @@ class MessageComposerPresenter( textEditorState = textEditorState, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, + isInThreadTimeline = isInThread, showAttachmentSourcePicker = showAttachmentSourcePicker, showTextFormatting = showTextFormatting, canShareLocation = canShareLocation.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index f3fdb3d59a..b4da119d96 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -21,6 +21,7 @@ data class MessageComposerState( val textEditorState: TextEditorState, val isFullScreen: Boolean, val mode: MessageComposerMode, + val isInThreadTimeline: Boolean, val showAttachmentSourcePicker: Boolean, val showTextFormatting: Boolean, val canShareLocation: Boolean, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index ef9cd7933b..b08fea28ec 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -29,6 +29,7 @@ fun aMessageComposerState( textEditorState: TextEditorState = aTextEditorStateRich(), isFullScreen: Boolean = false, mode: MessageComposerMode = MessageComposerMode.Normal, + isInThreadTimeline: Boolean = false, showTextFormatting: Boolean = false, showAttachmentSourcePicker: Boolean = false, canShareLocation: Boolean = true, @@ -39,6 +40,7 @@ fun aMessageComposerState( textEditorState = textEditorState, isFullScreen = isFullScreen, mode = mode, + isInThreadTimeline = isInThreadTimeline, showTextFormatting = showTextFormatting, showAttachmentSourcePicker = showAttachmentSourcePicker, canShareLocation = canShareLocation, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index 245b23cf8e..cd20f3e58b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -102,6 +102,7 @@ internal fun MessageComposerView( onRequestFocus = ::onRequestFocus, onSendMessage = ::sendMessage, composerMode = state.mode, + isInThreadTimeline = state.isInThreadTimeline, showTextFormatting = state.showTextFormatting, onResetComposerMode = ::onCloseSpecialMode, onAddAttachment = ::onAddAttachment, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DisplayablePinnedEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DisplayablePinnedEvents.kt new file mode 100644 index 0000000000..55c4b61ed1 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/DisplayablePinnedEvents.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned + +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem + +internal fun List.keepDisplayablePinnedEvents(): List { + val items = filter { it.isDisplayableAsPinnedEvent() } + return items.filterIndexed { index, item -> + !item.isDayDivider() || items.getOrNull(index + 1) is MatrixTimelineItem.Event + } +} + +internal fun EventContent.isDisplayableAsPinnedEvent(): Boolean { + return when (this) { + UnknownContent, + is FailedToParseMessageLikeContent, + is FailedToParseStateContent -> false + else -> true + } +} + +private fun MatrixTimelineItem.isDisplayableAsPinnedEvent(): Boolean { + return this !is MatrixTimelineItem.Event || event.content.isDisplayableAsPinnedEvent() +} + +private fun MatrixTimelineItem.isDayDivider(): Boolean { + return this is MatrixTimelineItem.Virtual && virtual is VirtualTimelineItem.DayDivider +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt index d1d53b36b4..0420416bff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactory.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.pinned.banner import androidx.compose.ui.text.AnnotatedString import dev.zacsweers.metro.Inject +import io.element.android.features.messages.impl.pinned.isDisplayableAsPinnedEvent import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.eventformatter.api.PinnedMessagesBannerFormatter import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -24,6 +25,7 @@ class PinnedMessagesBannerItemFactory( when (timelineItem) { is MatrixTimelineItem.Event -> { val eventId = timelineItem.eventId ?: return@withContext null + if (!timelineItem.event.content.isDisplayableAsPinnedEvent()) return@withContext null val formatted = formatter.format(timelineItem.event) PinnedMessagesBannerItem( eventId = eventId, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index 6e8974b871..e1ee18e920 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.pinned.DefaultPinnedEventsTimelineProvider +import io.element.android.features.messages.impl.pinned.keepDisplayablePinnedEvents import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactoryConfig @@ -207,7 +208,7 @@ class PinnedMessagesListPresenter( val timelineItemsFlow = asyncTimeline.data.timelineItems combine(timelineItemsFlow, room.membersStateFlow) { items, membersState -> timelineItemsFactory.replaceWith( - timelineItems = items, + timelineItems = items.keepDisplayablePinnedEvents(), roomMembers = membersState.roomMembers().orEmpty(), renderReadReceipts = false, ) @@ -239,7 +240,7 @@ class PinnedMessagesListPresenter( AsyncData.Uninitialized, is AsyncData.Loading -> PinnedMessagesListState.Loading is AsyncData.Failure -> PinnedMessagesListState.Failed is AsyncData.Success -> { - if (timelineItems.data.isEmpty()) { + if (timelineItems.data.all { it is TimelineItem.Virtual }) { PinnedMessagesListState.Empty } else { val actionListState = actionListPresenter.present() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index b56268eb02..b7fd9f572f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -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 = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index fed2ab6577..b0be2dde88 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -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( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index c90a0e5acc..a14d3b3893 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -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() + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 4ab4ee84a6..a7f7b33989 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.location.api.LocalMapTilerConfig import io.element.android.features.location.api.StaticMapView import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider @@ -50,6 +51,7 @@ fun TimelineItemLocationView( modifier = Modifier .fillMaxWidth() .heightIn(max = 188.dp), + mapTilerConfig = LocalMapTilerConfig.current, pinVariant = content.pinVariant, location = content.location, zoom = 15.0, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt index 007e809972..407cd29026 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt @@ -86,7 +86,7 @@ private fun ReadReceiptBottomSheetContent( LazyColumn { item { ListItem( - headlineContent = { + content = { Text(text = stringResource(id = CommonStrings.common_seen_by)) } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt index f29f23e903..af3e18e3cc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemEventFactory.kt @@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.ui.messages.reply.map import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +private const val GROUPING_TIMEOUT_MS = 5 * 60 * 1000L + @AssistedInject class TimelineItemEventFactory( @Assisted private val config: TimelineItemsFactoryConfig, @@ -221,8 +223,14 @@ class TimelineItemEventFactory( val previousSender = prevTimelineItem?.event?.sender val nextSender = nextTimelineItem?.event?.sender - val previousIsGroupable = prevTimelineItem?.canBeDisplayedInBubbleBlock().orTrue() - val nextIsGroupable = nextTimelineItem?.canBeDisplayedInBubbleBlock().orTrue() + val previousIsGroupable = prevTimelineItem?.let { + it.canBeDisplayedInBubbleBlock() && + currentTimelineItem.event.timestamp - it.event.timestamp <= GROUPING_TIMEOUT_MS + }.orTrue() + val nextIsGroupable = nextTimelineItem?.let { + it.canBeDisplayedInBubbleBlock() && + it.event.timestamp - currentTimelineItem.event.timestamp <= GROUPING_TIMEOUT_MS + }.orTrue() return when { previousSender != currentSender && nextSender == currentSender -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index c60e3847da..01146a070b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt @@ -58,10 +58,12 @@ fun TimelineItemEventContent.canBeForwarded(): Boolean = is TimelineItemFileContent, is TimelineItemAudioContent, is TimelineItemVideoContent, - is TimelineItemLocationContent, is TimelineItemVoiceContent, is TimelineItemGalleryContent, is TimelineItemAttachmentsContent -> true + // Live location shares can't be forwarded, the SDK rejects them, so we only show the option for static locations + // See https://github.com/element-hq/element-x-android/issues/7190 + is TimelineItemLocationContent -> mode is TimelineItemLocationContent.Mode.Static // Stickers can't be forwarded (yet) so we don't show the option // See https://github.com/element-hq/element-x-android/issues/2161 is TimelineItemStickerContent -> false diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt index 22be4acb2d..6110a2c4db 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenter.kt @@ -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 { - private val allowedEvents = mutableStateSetOf() - @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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt new file mode 100644 index 0000000000..aa6655cb86 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionStore.kt @@ -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 + fun allowEvent(eventId: EventId) +} + +@ContributesBinding(SessionScope::class) +@SingleIn(SessionScope::class) +class DefaultTimelineProtectionStore : TimelineProtectionStore { + private val allowedEvents = mutableStateSetOf() + + override val allowedEventIds: SnapshotStateSet = allowedEvents + + override fun allowEvent(eventId: EventId) { + allowedEvents += eventId + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt index f74d3b5f30..9faf42d3fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt @@ -28,6 +28,8 @@ import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.wysiwyg.view.spans.CodeBlockSpan import io.element.android.wysiwyg.view.spans.InlineCodeSpan +private val MATRIX_URI_REGEX = Regex("""matrix:(?:u|user|r|room|roomid|e|event)/\S*[^\s.,;:!?)]""", RegexOption.IGNORE_CASE) + interface TextPillificationHelper { fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence } @@ -87,6 +89,7 @@ class DefaultTextPillificationHelper( } private fun pillifyPermalinks(text: SpannableStringBuilder) { + pillifyMatrixUris(text) for (match in Patterns.WEB_URL.toRegex().findAll(text)) { val start = match.range.first val end = match.range.last + 1 @@ -99,6 +102,21 @@ class DefaultTextPillificationHelper( } } + private fun pillifyMatrixUris(text: SpannableStringBuilder) { + for (match in MATRIX_URI_REGEX.findAll(text)) { + val start = match.range.first + val end = match.range.last + 1 + if (!text.canPillify(start, end)) continue + val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, match.value) + if (mentionSpan != null) { + text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if (text.getSpans(start, end).isEmpty()) { + text.setSpan(URLSpan(match.value), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + } + private fun Spanned.canPillify(start: Int, end: Int): Boolean { if (getMentionSpans(start, end).isNotEmpty()) return false if (getSpans(start, end).isNotEmpty()) return false diff --git a/features/messages/impl/src/main/res/values-hu/translations.xml b/features/messages/impl/src/main/res/values-hu/translations.xml index 2b428f92b2..98841bf995 100644 --- a/features/messages/impl/src/main/res/values-hu/translations.xml +++ b/features/messages/impl/src/main/res/values-hu/translations.xml @@ -70,6 +70,10 @@ "Több megjelenítése" "Reakció-összefoglaló megjelenítése" "Új" + + "%1$d eltávolított üzenet" + "%1$d eltávolított üzenet" + "%1$d szobaváltozás" "%1$d szobaváltozás" diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml index 87ed6250c0..ac94594e99 100644 --- a/features/messages/impl/src/main/res/values-pl/translations.xml +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -32,7 +32,7 @@ "Przetwarzanie…" "Dodaj media" "Zablokuj użytkownika" - "Sprawdź, czy chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika." + "Zaznacz, jeśli chcesz ukryć wszystkie bieżące i przyszłe wiadomości od tego użytkownika." "Ta wiadomość zostanie zgłoszona do administratora Twojego serwera domowego. Nie będzie mógł on przeczytać żadnych zaszyfrowanych wiadomości." "Powód zgłoszenia treści" "Kamera" diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml index e9e4da87c3..d26a35bfc2 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -7,6 +7,19 @@ "Criptat de un dispozitiv necunoscut sau șters." "Criptat de un dispozitiv care nu este verificat de proprietarul său." "Criptat de un utilizator neverificat." + "Răsturnați imaginea pe orizontală" + "Răsturnat pe orizontală" + "Original" + "Răsturnați imaginea pe verticală" + "Răsturnat pe verticală" + "Original" + "Rotiți imaginea la stânga" + + "%1$d grad" + "%1$d grade" + "%1$d grade" + + "Editați poza" "Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi." "Atingeți pentru a modifica calitatea încărcării videoclipului" "Fișierul nu a putut fi încărcat." @@ -17,6 +30,7 @@ "Elementul %1$d din %2$d" "Optimizați calitatea imaginii" "Se procesează…" + "Adăugați media" "Blocați utilizatorul" "Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator" "Acest mesaj va fi raportat administratorilor homeserver-ului tau. Ei nu vor putea citi niciun mesaj criptat." @@ -59,6 +73,11 @@ "Afișați mai mult" "Afișați rezumatul reacțiilor" "Nou" + + "%1$d mesaj șters" + "%1$d mesaje șterse" + "%1$d mesaje șterse" + "%1$d schimbare a camerii" "%1$d schimbări ale camerei" diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml index e8cd285b5e..db7d96cd97 100644 --- a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml @@ -7,6 +7,17 @@ "由未知或已刪除的裝置加密。" "由未經其擁有者驗證的裝置加密。" "由未經驗證的使用者加密。" + "水平翻轉圖片" + "水平翻轉" + "原始狀態" + "垂直翻轉圖片" + "垂直翻轉" + "原始狀態" + "向左旋轉圖片" + + "%1$d 度" + + "編輯照片" "使用舊應用程式的使用者可能看不到標題。" "輕點即可變更影片上傳品質" "無法上傳檔案。" @@ -17,6 +28,7 @@ "第 %1$d 個項目,共 %2$d 個" "最佳化影像品質" "正在處理……" + "新增媒體" "封鎖使用者" "檢查您是否要隱藏所有來自此使用者的目前及未來的訊息" "此訊息將會回報給您的家伺服器管理員。他們將無法讀取任何已加密的訊息。" @@ -55,6 +67,9 @@ "更多" "顯示反應摘要" "新訊息" + + "%1$d 則已移除的訊息" + "%1$d 個聊天室變更" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt index fb597df290..e987a32480 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt @@ -21,7 +21,10 @@ import io.element.android.features.messages.impl.timeline.model.event.RtcNotific import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent +import io.element.android.features.messages.impl.timeline.model.event.aLiveLocationMode +import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent @@ -601,6 +604,123 @@ class ActionListPresenterTest { } } + @Test + fun `present - compute for a static location item`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + presenter.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemLocationContent(mode = aStaticLocationMode()), + ) + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvent.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for a live location item does not offer to forward it`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + presenter.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemLocationContent(mode = aLiveLocationMode(isActive = true)), + ) + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), + ) + ) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + event = messageEvent, + sentTimeFull = "0 Full true", + displayEmojiReactions = true, + verifiedUserSendFailure = VerifiedUserSendFailure.None, + actions = persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.CopyLink, + TimelineItemAction.Pin, + TimelineItemAction.ViewSource, + TimelineItemAction.Redact, + ), + recentEmojis = suggestedEmojis, + ) + ) + initialState.eventSink.invoke(ActionListEvent.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for an ended live location item does not offer to forward it`() = runTest { + val presenter = createActionListPresenter(isDeveloperModeEnabled = true) + presenter.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + isEditable = false, + content = aTimelineItemLocationContent(mode = aLiveLocationMode(isActive = false)), + ) + initialState.eventSink.invoke( + ActionListEvent.ComputeForMessage( + event = messageEvent, + userEventPermissions = aUserEventPermissions( + canRedactOwn = true, + canRedactOther = false, + canSendMessage = true, + canSendReaction = true, + canPinUnpin = true, + ), + ) + ) + val successState = awaitItem() + assertThat((successState.target as ActionListState.Target.Success).actions) + .doesNotContain(TimelineItemAction.Forward) + } + } + @Test fun `present - compute for a media with caption item`() = runTest { val presenter = createActionListPresenter(isDeveloperModeEnabled = true) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index fda8621087..07b9a4e330 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -61,6 +61,7 @@ import io.element.android.libraries.matrix.test.A_CAPTION import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_REPLY import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 @@ -148,12 +149,25 @@ class MessageComposerPresenterTest : RobolectricTest() { assertThat(initialState.isFullScreen).isFalse() assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) + assertThat(initialState.isInThreadTimeline).isFalse() assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() assertThat(initialState.slashCommandAction).isEqualTo(AsyncAction.Uninitialized) } } + @Test + fun `present - when a threadRoot is provided, isInThreadTimeline is true`() = runTest { + val presenter = createPresenter( + threadRoot = A_THREAD_ID, + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) + assertThat(initialState.isInThreadTimeline).isTrue() + } + } + @Test fun `present - toggle fullscreen`() = runTest { val presenter = createPresenter() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactoryTest.kt new file mode 100644 index 0000000000..114eed98d9 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerItemFactoryTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.pinned.banner + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBannerFormatter +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.EventContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PinnedMessagesBannerItemFactoryTest { + @Test + fun `create - returns an item for an event which can be displayed`() = runTest { + val factory = createPinnedMessagesBannerItemFactory() + val item = factory.create(anEventTimelineItemOf(aMessageContent("A message"))) + assertThat(item?.eventId).isEqualTo(AN_EVENT_ID) + } + + @Test + fun `create - returns null for events which cannot be displayed`() = runTest { + val factory = createPinnedMessagesBannerItemFactory() + val contents = listOf( + UnknownContent, + FailedToParseMessageLikeContent(eventType = "m.room.message", error = "an error"), + FailedToParseStateContent(eventType = "m.room.topic", stateKey = "", error = "an error"), + ) + for (content in contents) { + assertThat(factory.create(anEventTimelineItemOf(content))).isNull() + } + } + + @Test + fun `create - returns null for virtual items`() = runTest { + val factory = createPinnedMessagesBannerItemFactory() + val item = factory.create( + MatrixTimelineItem.Virtual( + uniqueId = A_UNIQUE_ID, + virtual = VirtualTimelineItem.DayDivider(timestamp = 0L), + ) + ) + assertThat(item).isNull() + } + + private fun anEventTimelineItemOf(content: EventContent) = MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = content, + ), + ) + + private fun TestScope.createPinnedMessagesBannerItemFactory() = PinnedMessagesBannerItemFactory( + coroutineDispatchers = testCoroutineDispatchers(), + formatter = FakePinnedMessagesBannerFormatter( + formatLambda = { event -> "${event.content}" } + ), + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt index 1f95baccc9..256837e5f7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/banner/PinnedMessagesBannerPresenterTest.kt @@ -14,6 +14,7 @@ import io.element.android.libraries.eventformatter.test.FakePinnedMessagesBanner import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_UNIQUE_ID @@ -24,6 +25,7 @@ import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.flow.flowOf @@ -90,6 +92,46 @@ class PinnedMessagesBannerPresenterTest { } } + @Test + fun `present - loaded state - events which cannot be displayed are not rendered`() = runTest { + val messageContent = aMessageContent("A message") + val pinnedEventsTimeline = FakeTimeline( + timelineItems = flowOf( + listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = messageContent, + ), + ), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = UnknownContent, + ), + ) + ) + ) + ) + val room = FakeJoinedRoom( + createTimelineResult = { Result.success(pinnedEventsTimeline) } + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2))) + } + val presenter = createPinnedMessagesBannerPresenter(room = room) + presenter.test { + val loadedState = consumeItemsUntilPredicate { state -> + state is PinnedMessagesBannerState.Loaded + }.last() as PinnedMessagesBannerState.Loaded + assertThat(loadedState.loadedPinnedMessagesCount).isEqualTo(1) + assertThat(loadedState.currentPinnedMessageIndex).isEqualTo(0) + assertThat(loadedState.currentPinnedMessage.formatted.text).isEqualTo(messageContent.toString()) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - loaded state - multiple pinned messages`() = runTest { val messageContent1 = aMessageContent("A message") diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 8b2da2cefd..3efa707c3d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -21,13 +21,19 @@ import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvi import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent +import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent +import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_UNIQUE_ID +import io.element.android.libraries.matrix.test.A_UNIQUE_ID_2 import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.aRoomInfo @@ -38,6 +44,7 @@ import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -131,6 +138,90 @@ class PinnedMessagesListPresenterTest { } } + @Test + fun `present - filled state - events which cannot be displayed are not rendered`() = runTest { + val pinnedEventsTimeline = createPinnedMessagesTimeline( + items = listOf( + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("aDayDivider"), + virtual = VirtualTimelineItem.DayDivider(timestamp = 0L), + ), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = aMessageContent("A message"), + ), + ), + MatrixTimelineItem.Virtual( + uniqueId = UniqueId("anotherDayDivider"), + virtual = VirtualTimelineItem.DayDivider(timestamp = 1L), + ), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID_2, + content = UnknownContent, + ), + ), + ), + ) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID, AN_EVENT_ID_2))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room) + presenter.test { + val filledState = consumeItemsUntilPredicate { state -> + state is PinnedMessagesListState.Filled + }.last() as PinnedMessagesListState.Filled + assertThat(filledState.loadedPinnedMessagesCount).isEqualTo(1) + assertThat(filledState.timelineItems).hasSize(2) + assertThat(filledState.timelineItems.filterIsInstance().map { it.eventId }).containsExactly(AN_EVENT_ID) + assertThat(filledState.timelineItems.filterIsInstance()).hasSize(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - empty state when no pinned event can be displayed`() = runTest { + val pinnedEventsTimeline = createPinnedMessagesTimeline( + items = listOf( + MatrixTimelineItem.Virtual( + uniqueId = A_UNIQUE_ID, + virtual = VirtualTimelineItem.DayDivider(timestamp = 0L), + ), + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID_2, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = FailedToParseMessageLikeContent(eventType = "m.room.message", error = "an error"), + ), + ), + ), + ) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + ).apply { + givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) + }, + createTimelineResult = { Result.success(pinnedEventsTimeline) }, + ) + val presenter = createPinnedMessagesListPresenter(room = room) + presenter.test { + val emptyState = consumeItemsUntilPredicate { state -> + state is PinnedMessagesListState.Empty + }.last() + assertThat(emptyState).isEqualTo(PinnedMessagesListState.Empty) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - unpin event`() = runTest { val successUnpinEventLambda = lambdaRecorder { _: EventId? -> Result.success(true) } @@ -263,21 +354,18 @@ class PinnedMessagesListPresenterTest { } } - private fun createPinnedMessagesTimeline(): FakeTimeline { - val messageContent = aMessageContent("A message") - return FakeTimeline( - timelineItems = flowOf( - listOf( - MatrixTimelineItem.Event( - uniqueId = A_UNIQUE_ID, - event = anEventTimelineItem( - eventId = AN_EVENT_ID, - content = messageContent, - ), - ) - ) + private fun createPinnedMessagesTimeline( + items: List = listOf( + MatrixTimelineItem.Event( + uniqueId = A_UNIQUE_ID, + event = anEventTimelineItem( + eventId = AN_EVENT_ID, + content = aMessageContent("A message"), + ), ) - ) + ), + ): FakeTimeline { + return FakeTimeline(timelineItems = flowOf(items)) } private fun roomPermissions( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index bcddc37a74..f733f116d6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -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() + 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() + 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." diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt new file mode 100644 index 0000000000..3f5805a0b4 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ExpandSheetWhenImeIsVisibleTest.kt @@ -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 { + val sheetState = renderWithIme(isImeVisible = false) + assertThat(sheetState.currentValue).isEqualTo(SheetValue.PartiallyExpanded) + } + + @Test + fun `the sheet is expanded when the keyboard is shown`() = runAndroidComposeUiTest { + val sheetState = renderWithIme(isImeVisible = true) + assertThat(sheetState.currentValue).isEqualTo(SheetValue.Expanded) + } +} + +private fun AndroidComposeUiTest.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 +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt new file mode 100644 index 0000000000..53c39ee530 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt @@ -0,0 +1,122 @@ +/* + * 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.factories + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TimelineItemsFactoryTest { + @Test + fun `messages sent close together are grouped`() = runTest { + val positions = groupPositionsOf( + timestamps = listOf(0L, ONE_MINUTE, 2 * ONE_MINUTE), + ) + assertThat(positions).containsExactly( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Middle, + TimelineItemGroupPosition.Last, + ).inOrder() + } + + @Test + fun `messages sent more than five minutes apart are not grouped`() = runTest { + val positions = groupPositionsOf( + timestamps = listOf(0L, 6 * ONE_MINUTE, 12 * ONE_MINUTE), + ) + assertThat(positions).containsExactly( + TimelineItemGroupPosition.None, + TimelineItemGroupPosition.None, + TimelineItemGroupPosition.None, + ).inOrder() + } + + @Test + fun `a gap of more than five minutes splits the group`() = runTest { + val positions = groupPositionsOf( + timestamps = listOf(0L, ONE_MINUTE, 8 * ONE_MINUTE, 9 * ONE_MINUTE), + ) + assertThat(positions).containsExactly( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Last, + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Last, + ).inOrder() + } + + @Test + fun `a gap of exactly five minutes still groups`() = runTest { + val positions = groupPositionsOf( + timestamps = listOf(0L, FIVE_MINUTES), + ) + assertThat(positions).containsExactly( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Last, + ).inOrder() + } + + @Test + fun `messages with the same timestamp are grouped`() = runTest { + val positions = groupPositionsOf( + timestamps = listOf(0L, 0L, 0L), + ) + assertThat(positions).containsExactly( + TimelineItemGroupPosition.First, + TimelineItemGroupPosition.Middle, + TimelineItemGroupPosition.Last, + ).inOrder() + } + + private suspend fun TestScope.groupPositionsOf(timestamps: List): List { + val factory = aTimelineItemsFactory( + config = TimelineItemsFactoryConfig( + computeReadReceipts = false, + computeReactions = false, + ) + ) + val items = timestamps.mapIndexed { index, timestamp -> + MatrixTimelineItem.Event( + uniqueId = UniqueId("event-$index"), + event = anEventTimelineItem( + sender = A_USER_ID, + timestamp = timestamp, + content = aMessageContent(body = "Message $index"), + ), + ) + } + var positions: List = emptyList() + factory.timelineItems.test { + factory.replaceWith( + timelineItems = items, + roomMembers = emptyList(), + renderReadReceipts = false, + ) + positions = awaitItem() + .filterIsInstance() + .map { it.groupPosition } + .reversed() + cancelAndIgnoreRemainingEvents() + } + return positions + } + + private companion object { + const val ONE_MINUTE = 60 * 1000L + const val FIVE_MINUTES = 5 * ONE_MINUTE + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt index 11142cc9a2..161b350c96 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/protection/TimelineProtectionPresenterTest.kt @@ -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, _: ContentValidationState -> }, + timelineProtectionStore: TimelineProtectionStore = DefaultTimelineProtectionStore(), ) = TimelineProtectionPresenter( mediaPreviewService = mediaPreviewService, room = room, contentScannerService = contentScannerService, + timelineProtectionStore = timelineProtectionStore, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt index 6390c2ba48..2590c9fc5c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt @@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.utils import android.net.Uri +import android.text.Spanned +import android.text.style.URLSpan +import androidx.core.text.getSpans import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias @@ -30,6 +33,75 @@ import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test class DefaultTextPillificationHelperTest : RobolectricTest() { + @Test + fun `pillify - adds pills for matrix uri user links`() { + val text = "Hello matrix:u/someone:example.com" + val userId = UserId("@someone:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(userId) }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@someone:example.com") + }), + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + assertThat(mentionSpans.first().type).isInstanceOf(MentionType.User::class.java) + val spanned = pillified as Spanned + assertThat(spanned.getSpanStart(mentionSpans.first())).isEqualTo(text.indexOf("matrix:")) + assertThat(spanned.getSpanEnd(mentionSpans.first())).isEqualTo(text.length) + val urlSpans = spanned.getSpans(0, spanned.length) + assertThat(urlSpans).hasLength(1) + assertThat(urlSpans.first().url).isEqualTo("matrix:u/someone:example.com") + } + + @Test + fun `pillify - adds pills for matrix uri room links`() { + val text = "Join matrix:r/room:example.com now" + val roomAlias = RoomAlias("#room:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.RoomLink(roomIdOrAlias = roomAlias.toRoomIdOrAlias()) + }), + permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { + Result.success("https://matrix.to/#/#room:example.com") + }), + ) + val mentionSpans = helper.pillify(text).getMentionSpans() + assertThat(mentionSpans).hasSize(1) + assertThat(mentionSpans.first().type).isInstanceOf(MentionType.Room::class.java) + } + + @Test + fun `pillify - a matrix uri does not swallow trailing punctuation`() { + val text = "See matrix:u/someone:example.com." + val userId = UserId("@someone:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(userId) }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@someone:example.com") + }), + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + assertThat((pillified as Spanned).getSpanEnd(mentionSpans.first())).isEqualTo(text.length - 1) + } + + @Test + fun `pillify - does not pillify matrix uris when permalinks are disabled`() { + val text = "Hello matrix:u/someone:example.com" + val userId = UserId("@someone:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(userId) }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@someone:example.com") + }), + ) + val pillified = helper.pillify(text, pillifyPermalinks = false) + assertThat(pillified.getMentionSpans()).isEmpty() + } + @Test fun `pillify - adds pills for user ids`() { val text = "A @user:server.com" diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt index a46a2db781..b33a00c165 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/EndPollAction.kt @@ -11,6 +11,13 @@ package io.element.android.features.poll.api.actions import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline +/** + * Closes a poll so no further answers are accepted, wrapping the timeline call so the analytics are captured in one place. + */ interface EndPollAction { + /** + * @param timeline the timeline the poll lives in. + * @param pollStartId the poll start event to close. + */ suspend fun execute(timeline: Timeline, pollStartId: EventId): Result } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt index 00b60ed5ae..cbaace50fb 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -11,7 +11,15 @@ package io.element.android.features.poll.api.actions import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline +/** + * Sends the current user's answer to a poll, wrapping the timeline call so the analytics are captured in one place. + */ interface SendPollResponseAction { + /** + * @param timeline the timeline the poll lives in. + * @param pollStartId the poll start event being answered. + * @param answerId the id of the chosen answer. + */ suspend fun execute( timeline: Timeline, pollStartId: EventId, diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt index c9bd31ea4f..f9b4a56efc 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFactory.kt @@ -12,7 +12,16 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.PollContent +/** + * Turns the poll content of a timeline event into the state the poll UI renders. + */ interface PollContentStateFactory { + /** + * Convenience overload that reads the editability and ownership from the timeline item itself. + * + * @param eventTimelineItem the timeline item carrying the poll. + * @param content the poll content of that item. + */ suspend fun create(eventTimelineItem: EventTimelineItem, content: PollContent): PollContentState { return create( eventId = eventTimelineItem.eventId, @@ -21,5 +30,12 @@ interface PollContentStateFactory { content = content, ) } + + /** + * @param eventId the poll start event, or `null` while the poll is still a local echo. + * @param isEditable whether the current user may still edit the poll. + * @param isOwn whether the poll was created by the current user. + * @param content the poll question, answers and votes. + */ suspend fun create(eventId: EventId?, isEditable: Boolean, isOwn: Boolean, content: PollContent): PollContentState } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index 3abf3718af..6163fe19ad 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -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)) }, ) diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt index 82fcd1053f..9dc3eb1877 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/CacheService.kt @@ -11,6 +11,9 @@ package io.element.android.features.preferences.api import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow +/** + * Announces that a session's cache has been cleared, since clearing it destroys the client and the app has to react. + */ interface CacheService { /** * A flow of [SessionId], can let the app to know when the diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ExtraDeveloperOptionsRenderer.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ExtraDeveloperOptionsRenderer.kt index 7d7eec9d8b..3acb2c9057 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ExtraDeveloperOptionsRenderer.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/ExtraDeveloperOptionsRenderer.kt @@ -14,6 +14,11 @@ import androidx.compose.ui.Modifier * Component for rendering extra developer options in the preferences screen. */ interface ExtraDeveloperOptionsRenderer { + /** + * Draws the extra options. The standard build binds a no-op implementation, so only the builds that actually add options provide a real one. + * + * @param modifier layout modifier for the container. + */ @Composable fun Render(modifier: Modifier) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt index b71db181e8..3a6671f692 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt @@ -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, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index c108c45d3b..438b20049a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -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 diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt index 0db5c4c576..31e516e730 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersEvents.kt @@ -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 } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt index fc150d0bfc..2944089fa1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -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 { @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, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt index 8c478371ad..e4b07386c7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt @@ -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, val unblockUserAction: AsyncAction, + val snackbarMessage: SnackbarMessage?, val eventSink: (BlockedUsersEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt index 92cc8a0936..0e0e9e5ca5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStateProvider.kt @@ -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 { internal fun aBlockedUsersState( blockedUsers: List = aMatrixUserList(), unblockUserAction: AsyncAction = AsyncAction.Uninitialized, + snackbarMessage: SnackbarMessage? = null, eventSink: (BlockedUsersEvents) -> Unit = {}, ): BlockedUsersState { return BlockedUsersState( blockedUsers = blockedUsers.toImmutableList(), unblockUserAction = unblockUserAction, + snackbarMessage = snackbarMessage, eventSink = eventSink, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt index 40e58022bd..c4d8387295 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt @@ -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, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 02d2573ed6..ac1ba06cf6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -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, 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, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index fe48e8c446..700c989bfa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -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, val isEnterpriseBuild: Boolean, val showColorPicker: Boolean, + val deviceId: DeviceId, val eventSink: (DeveloperSettingsEvents) -> Unit ) { val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index fd285b1bb9..b14446d11c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -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 { @@ -40,6 +41,7 @@ fun aDeveloperSettingsState( markAllRoomsAsReadAction: AsyncAction = 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, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 0e5f4026ef..2fd2137451 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -15,12 +15,14 @@ import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView +import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -31,6 +33,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.ui.strings.CommonStrings import io.mhssn.colorpicker.ColorPickerDialog import io.mhssn.colorpicker.ColorPickerType @@ -75,13 +78,14 @@ fun DeveloperSettingsView( state = state.appDeveloperSettingsState, onOpenShowkase = onOpenShowkase, ) + SessionCategory(deviceId = state.deviceId) NotificationCategory(onPushHistoryClick) MarkAllRoomsAsReadCategory(state) if (state.isEnterpriseBuild) { PreferenceCategory(title = "Theme") { ListItem( - headlineContent = { + content = { Text("Change brand color") }, onClick = { @@ -89,7 +93,7 @@ fun DeveloperSettingsView( } ) ListItem( - headlineContent = { + content = { Text("Reset brand color") }, onClick = { @@ -104,7 +108,7 @@ fun DeveloperSettingsView( val cache = state.cacheSize PreferenceCategory(title = "Cache") { ListItem( - headlineContent = { Text("Database sizes") }, + content = { Text("Database sizes") }, supportingContent = { if (state.databaseSizes.isLoading()) { Text("Computing...") @@ -123,7 +127,7 @@ fun DeveloperSettingsView( } ) ListItem( - headlineContent = { + content = { Text("Vacuum stores") }, onClick = { @@ -131,7 +135,7 @@ fun DeveloperSettingsView( } ) ListItem( - headlineContent = { + content = { Text("Clear cache") }, trailingContent = if (state.cacheSize.isLoading() || state.clearCacheAction.isLoading()) { @@ -168,11 +172,29 @@ fun DeveloperSettingsView( ) } +@Composable +private fun SessionCategory(deviceId: DeviceId) { + PreferenceCategory(title = "Session") { + val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) + val context = LocalContext.current + ListItem( + content = { Text("DeviceId") }, + supportingContent = { Text(text = deviceId.value) }, + onClick = { + context.copyToClipboard( + text = deviceId.value, + toastMessage = toastMessage, + ) + } + ) + } +} + @Composable private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) { PreferenceCategory(title = "Room list") { ListItem( - headlineContent = { + content = { Text("Mark all rooms as read") }, supportingContent = { @@ -198,7 +220,7 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) { private fun NotificationCategory(onPushHistoryClick: () -> Unit) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { ListItem( - headlineContent = { + content = { Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title)) }, onClick = onPushHistoryClick, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt index e7ad6e2284..9c1127dd2a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt @@ -25,6 +25,7 @@ import io.element.android.features.rageshake.api.preferences.RageshakePreference import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.preferences.api.store.AppPreferencesStore @@ -42,6 +43,7 @@ class AppDeveloperSettingsPresenter( private val featureFlagService: FeatureFlagService, private val rageshakePresenter: Presenter, private val appPreferencesStore: AppPreferencesStore, + private val buildMeta: BuildMeta, ) : Presenter { @Composable override fun present(): AppDeveloperSettingsState { @@ -110,6 +112,8 @@ class AppDeveloperSettingsPresenter( ), tracingLogLevel = tracingLogLevel, tracingLogPacks = tracingLogPacks, + gitBranch = buildMeta.gitBranchName, + gitSha = buildMeta.gitRevision, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt index 1eb5fd7fd3..071670644c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsState.kt @@ -20,6 +20,8 @@ data class AppDeveloperSettingsState( val customElementCallBaseUrlState: CustomElementCallBaseUrlState, val tracingLogLevel: AsyncData, val tracingLogPacks: ImmutableList, + val gitBranch: String, + val gitSha: String, val eventSink: (AppDeveloperSettingsEvent) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt index 494b3b6bbd..e4536f4dba 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsStateProvider.kt @@ -37,6 +37,8 @@ fun anAppDeveloperSettingsState( customElementCallBaseUrlState = customElementCallBaseUrlState, tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), tracingLogPacks = traceLogPacks.toImmutableList(), + gitBranch = "feature/awesome-feature", + gitSha = "1234567890", eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt index 9b2a2aa3a9..a4f41edb4a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewParameter @@ -22,6 +23,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView +import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch @@ -33,6 +35,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.featureflag.ui.FeatureListView import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalComposeUiApi::class) @@ -81,7 +84,7 @@ fun AppDeveloperSettingsView( } PreferenceCategory(title = "Showkase") { ListItem( - headlineContent = { + content = { Text("Open Showkase browser") }, onClick = onOpenShowkase @@ -92,12 +95,47 @@ fun AppDeveloperSettingsView( ) PreferenceCategory(title = "Crash") { ListItem( - headlineContent = { + content = { Text("Crash the app 💥") }, onClick = { error("This crash is a test.") } ) } + GitCategory( + gitBranch = state.gitBranch, + gitSha = state.gitSha, + ) + } +} + +@Composable +private fun GitCategory( + gitBranch: String, + gitSha: String, +) { + PreferenceCategory(title = "Git") { + val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) + val context = LocalContext.current + ListItem( + content = { Text("Git branch") }, + supportingContent = { Text(text = gitBranch) }, + onClick = { + context.copyToClipboard( + text = gitBranch, + toastMessage = toastMessage, + ) + } + ) + ListItem( + content = { Text("Git SHA") }, + supportingContent = { Text(text = gitSha) }, + onClick = { + context.copyToClipboard( + text = gitSha, + toastMessage = toastMessage, + ) + } + ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index 2009970448..9ede5138cf 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -130,7 +130,7 @@ private fun NotificationSettingsContentView( if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.NotificationsOffSolid())), - headlineContent = { + content = { Text(stringResource(id = R.string.screen_notification_settings_system_notifications_turned_off)) }, supportingContent = { @@ -158,7 +158,7 @@ private fun NotificationSettingsContentView( PreferenceCategory { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VoiceCallSolid())), - headlineContent = { + content = { Text(stringResource(id = R.string.full_screen_intent_banner_title)) }, supportingContent = { @@ -172,7 +172,7 @@ private fun NotificationSettingsContentView( } PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_notification_section_title)) { ListItem( - headlineContent = { + content = { Text(stringResource(id = R.string.screen_notification_settings_group_chats)) }, supportingContent = { @@ -181,7 +181,7 @@ private fun NotificationSettingsContentView( onClick = onGroupChatsClick ) ListItem( - headlineContent = { + content = { Text(stringResource(id = R.string.screen_notification_settings_direct_chats)) }, supportingContent = { @@ -218,7 +218,7 @@ private fun NotificationSettingsContentView( } PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) { ListItem( - headlineContent = { + content = { Text(stringResource(id = R.string.troubleshoot_notifications_entry_point_title)) }, onClick = onTroubleshootNotificationsClick @@ -227,7 +227,7 @@ private fun NotificationSettingsContentView( if (state.showAdvancedSettings) { PreferenceCategory(title = stringResource(id = CommonStrings.common_advanced_settings)) { ListItem( - headlineContent = { + content = { Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) }, trailingContent = when (state.currentPushDistributor) { @@ -296,7 +296,7 @@ private fun SoundsPreferenceCategory(state: NotificationSettingsState) { } } ListItem( - headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_message_sound_label)) }, + content = { Text(stringResource(id = R.string.screen_notification_settings_message_sound_label)) }, supportingContent = { Text(state.messageSound.displayName) }, onClick = { state.eventSink(NotificationSettingsEvents.ShowMessageSoundDialog) }, ) @@ -323,7 +323,7 @@ private fun SoundsPreferenceCategory(state: NotificationSettingsState) { } } ListItem( - headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_call_ringtone_label)) }, + content = { Text(stringResource(id = R.string.screen_notification_settings_call_ringtone_label)) }, supportingContent = { Text(state.callRingtone.displayName) }, onClick = { state.eventSink(NotificationSettingsEvents.ShowCallRingtoneDialog) }, ) @@ -418,7 +418,7 @@ private fun SoundCopyErrorRow( modifier = Modifier .background(ElementTheme.colors.bgSubtleSecondary) .semantics { liveRegion = LiveRegionMode.Polite }, - headlineContent = { + content = { Text( text = stringResource(R.string.screen_notification_settings_sound_set_sound_error_title), style = ElementTheme.typography.fontBodyMdRegular, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt index f7fc251eeb..ac4ac3ad34 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt @@ -40,7 +40,7 @@ fun DefaultNotificationSettingOption( } ListItem( modifier = modifier, - headlineContent = { Text(title) }, + content = { Text(title) }, supportingContent = subtitle?.let { { Text(it) } }, trailingContent = ListItemContent.RadioButton(selected = isSelected), onClick = { onSelectOption(mode) }, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 3b46ae737e..24da141a48 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -88,7 +88,7 @@ fun EditDefaultNotificationSettingView( null -> "" } ListItem( - headlineContent = { + content = { val roomName = summary.name Text( text = roomName ?: stringResource(id = CommonStrings.common_no_room_name), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 3daeb5aec1..679e033d9b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -74,8 +74,14 @@ class PreferencesRootPresenter( val showLinkNewDevice by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) }.collectAsState(initial = false) - val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false) - val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null + + val isUserStatusSupported by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus) + .collect { isUserStatusEnabled -> + value = isUserStatusEnabled && matrixClient.isUserStatusSupported().getOrDefault(false) + } + } + val userStatusState = if (isUserStatusSupported) userStatusPresenter.present() else null val otherSessions by remember { sessionStore.sessionsFlow().map { list -> @@ -142,7 +148,6 @@ class PreferencesRootPresenter( myUser = matrixUser.value, userStatusState = userStatusState, version = remember { versionFormatter.get() }, - deviceId = matrixClient.deviceId, isMultiAccountEnabled = isMultiAccountEnabled, otherSessions = otherSessions, showSecureBackup = !canVerifyUserSession, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index c415547a80..72d7240f2e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -11,7 +11,6 @@ package io.element.android.features.preferences.impl.root import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage -import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -19,7 +18,6 @@ data class PreferencesRootState( val myUser: MatrixUser, val userStatusState: UserStatusState?, val version: String, - val deviceId: DeviceId?, val isMultiAccountEnabled: Boolean, val otherSessions: ImmutableList, val showSecureBackup: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 0abed1f66f..86f984f254 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -15,7 +15,6 @@ import io.element.android.features.preferences.impl.userstatus.UserStatusPickerS import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage -import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList @@ -29,7 +28,6 @@ open class PreferencesRootStateProvider : PreviewParameterProvider = emptyList(), showSecureBackup: Boolean = false, @@ -96,7 +92,6 @@ fun aPreferencesRootState( ) = PreferencesRootState( myUser = myUser, version = version, - deviceId = deviceId, isMultiAccountEnabled = isMultiAccountEnabled, otherSessions = otherSessions.toImmutableList(), showSecureBackup = showSecureBackup, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 7ec711fdeb..2a37309e26 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -8,12 +8,15 @@ package io.element.android.features.preferences.impl.root +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -26,7 +29,10 @@ import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.user.UserPreferences import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusView +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator +import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage @@ -43,7 +49,6 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer -import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.ui.strings.CommonStrings @@ -73,72 +78,87 @@ fun PreferencesRootView( ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) - // Include pref from other modules - PreferencePage( - modifier = modifier, - onBackClick = onBackClick, - title = stringResource(id = CommonStrings.common_settings), - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { - UserPreferences( - modifier = Modifier.clickable { - onOpenUserProfile(state.myUser) - }, - matrixUser = state.myUser, - ) - if (state.isMultiAccountEnabled) { - MultiAccountSection( - state = state, - onAddAccountClick = onAddAccountClick, + Box(modifier = modifier) { + // Include pref from other modules + PreferencePage( + onBackClick = onBackClick, + title = stringResource(id = CommonStrings.common_settings), + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { + UserPreferences( + modifier = Modifier.clickable { + onOpenUserProfile(state.myUser) + }, + matrixUser = state.myUser, ) - } - if (state.userStatusState != null) { - UserStatusSection( - userStatusState = state.userStatusState, - emojiPickerRenderer = emojiPickerRenderer, - showTopDivider = !state.isMultiAccountEnabled, - ) - } - // 'Account' section - ManageAccountSection( - state = state, - onManageAccountClick = onManageAccountClick, - onLinkNewDeviceClick = onLinkNewDeviceClick, - onOpenBlockedUsers = onOpenBlockedUsers - ) - // 'Manage my app' section - ManageAppSection( - state = state, - onOpenNotificationSettings = onOpenNotificationSettings, - onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClick = onSecureBackupClick, - ) - - // General section - GeneralSection( - state = state, - onOpenAbout = onOpenAbout, - onOpenAnalytics = onOpenAnalytics, - onOpenRageShake = onOpenRageShake, - onOpenAdvancedSettings = onOpenAdvancedSettings, - onOpenDeveloperSettings = onOpenDeveloperSettings, - onOpenLabs = onOpenLabs, - onSignOutClick = onSignOutClick, - onDeactivateClick = onDeactivateClick, - ) - // Version - Footer( - version = state.version, - deviceId = state.deviceId, - onClick = if (!state.showDeveloperSettings) { - { state.eventSink(PreferencesRootEvent.OnVersionInfoClick) } - } else { - null + if (state.isMultiAccountEnabled) { + MultiAccountSection( + state = state, + onAddAccountClick = onAddAccountClick, + ) } - ) + if (state.userStatusState != null) { + UserStatusSection( + userStatusState = state.userStatusState, + emojiPickerRenderer = emojiPickerRenderer, + showTopDivider = !state.isMultiAccountEnabled, + ) + } + // 'Account' section + ManageAccountSection( + state = state, + onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, + onOpenBlockedUsers = onOpenBlockedUsers + ) + // 'Manage my app' section + ManageAppSection( + state = state, + onOpenNotificationSettings = onOpenNotificationSettings, + onOpenLockScreenSettings = onOpenLockScreenSettings, + onSecureBackupClick = onSecureBackupClick, + ) + + // General section + GeneralSection( + state = state, + onOpenAbout = onOpenAbout, + onOpenAnalytics = onOpenAnalytics, + onOpenRageShake = onOpenRageShake, + onOpenAdvancedSettings = onOpenAdvancedSettings, + onOpenDeveloperSettings = onOpenDeveloperSettings, + onOpenLabs = onOpenLabs, + onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, + ) + // Version + Footer( + version = state.version, + onClick = if (!state.showDeveloperSettings) { + { state.eventSink(PreferencesRootEvent.OnVersionInfoClick) } + } else { + null + } + ) + } + state.userStatusState?.let { + UserStatusUpdateIndicator(it.updateStatusAction) + } } } +@Composable +private fun BoxScope.UserStatusUpdateIndicator(updateStatusAction: AsyncAction) { + AsyncActionIndicator( + asyncAction = updateStatusAction, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding(), + loading = { AsyncIndicator.Loading(text = stringResource(CommonStrings.common_saving)) }, + failure = { _ -> AsyncIndicator.Failure(text = stringResource(CommonStrings.common_failed)) }, + ) +} + @Composable private fun ColumnScope.UserStatusSection( userStatusState: UserStatusState, @@ -185,7 +205,7 @@ private fun ColumnScope.MultiAccountSection( } ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), - headlineContent = { + content = { Text(stringResource(CommonStrings.common_add_another_account)) }, onClick = onAddAccountClick, @@ -204,18 +224,18 @@ private fun ColumnScope.ManageAppSection( onSecureBackupClick: () -> Unit, ) { ListItem( - headlineContent = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, + content = { Text(stringResource(id = R.string.screen_notification_settings_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), onClick = onOpenNotificationSettings, ) ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, + content = { Text(stringResource(id = CommonStrings.common_screen_lock)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onOpenLockScreenSettings, ) if (state.showSecureBackup) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_encryption)) }, + content = { Text(stringResource(id = CommonStrings.common_encryption)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Key())), trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, onClick = onSecureBackupClick, @@ -233,7 +253,7 @@ private fun ColumnScope.ManageAccountSection( ) { state.accountManagementUrl?.let { url -> ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) }, + content = { Text(stringResource(id = CommonStrings.action_manage_account_and_devices)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), onClick = { onManageAccountClick(url) }, @@ -241,14 +261,14 @@ private fun ColumnScope.ManageAccountSection( } if (state.showLinkNewDevice) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, + content = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), onClick = onLinkNewDeviceClick, ) } if (state.showBlockedUsersItem) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, + content = { Text(stringResource(id = CommonStrings.common_blocked_users)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), onClick = onOpenBlockedUsers, trailingContent = ListItemContent.Text(state.nbOfBlockedUsers.toString()), @@ -272,53 +292,55 @@ private fun ColumnScope.GeneralSection( onDeactivateClick: () -> Unit, ) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, + content = { Text(stringResource(id = CommonStrings.common_advanced_settings)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), onClick = onOpenAdvancedSettings, ) if (state.showLabsItem) { ListItem( - headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) }, + content = { Text(stringResource(id = R.string.screen_labs_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())), onClick = onOpenLabs, ) } ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, + content = { Text(stringResource(id = CommonStrings.common_about)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), onClick = onOpenAbout, ) if (state.canReportBug) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, + content = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), onClick = onOpenRageShake ) } if (state.showAnalyticsSettings) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_analytics)) }, + content = { Text(stringResource(id = CommonStrings.common_analytics)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Chart())), onClick = onOpenAnalytics, ) } HorizontalDivider() ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) }, + content = { Text(stringResource(id = CommonStrings.action_signout)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), style = ListItemStyle.Destructive, onClick = onSignOutClick, ) if (state.canDeactivateAccount) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.action_delete_account)) }, + content = { Text(stringResource(id = CommonStrings.action_delete_account)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), style = ListItemStyle.Destructive, onClick = onDeactivateClick, ) } // Put developer settings at the end, so nothing bad happens if the user clicks 8 times to enable the entry - if (state.showDeveloperSettings) { + AnimatedVisibility( + visible = state.showDeveloperSettings, + ) { DeveloperPreferencesView(onOpenDeveloperSettings) } } @@ -326,25 +348,15 @@ private fun ColumnScope.GeneralSection( @Composable private fun ColumnScope.Footer( version: String, - deviceId: DeviceId?, onClick: (() -> Unit)?, ) { - val text = remember(version, deviceId) { - buildString { - append(version) - if (deviceId != null) { - append("\n") - append(deviceId) - } - } - } Text( modifier = Modifier .align(Alignment.CenterHorizontally) .clickable(enabled = onClick != null, onClick = onClick ?: {}) .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 24.dp), textAlign = TextAlign.Center, - text = text, + text = version, style = ElementTheme.typography.fontBodySmRegular, color = ElementTheme.colors.textSecondary, ) @@ -353,7 +365,7 @@ private fun ColumnScope.Footer( @Composable private fun DeveloperPreferencesView(onOpenDeveloperSettings: () -> Unit) { ListItem( - headlineContent = { Text(stringResource(id = CommonStrings.common_developer_options)) }, + content = { Text(stringResource(id = CommonStrings.common_developer_options)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), onClick = onOpenDeveloperSettings ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt index 52517b5ca2..6cd41376d1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/VersionFormatter.kt @@ -24,16 +24,10 @@ class DefaultVersionFormatter( private val buildMeta: BuildMeta, ) : VersionFormatter { override fun get(): String { - val base = stringProvider.getString( + return stringProvider.getString( CommonStrings.settings_version_number, buildMeta.versionName, buildMeta.versionCode.toString() ) - return if (buildMeta.gitBranchName == "main") { - base - } else { - // In case of a build not from main, we display the branch name and the revision - "$base\n${buildMeta.gitBranchName} (${buildMeta.gitRevision})" - } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt index 60f02ad05a..2358b3502d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenter.kt @@ -18,7 +18,9 @@ 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.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis import io.element.android.libraries.matrix.api.MatrixClient @@ -38,6 +40,7 @@ class UserStatusPresenter( var isEmojiPickerVisible by remember { mutableStateOf(false) } val customTextFieldState = rememberTextFieldState() val coroutineScope = rememberCoroutineScope() + val updateStatusAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } fun handleEvent(event: UserStatusEvent) { when (event) { @@ -47,11 +50,15 @@ class UserStatusPresenter( UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden is UserStatusEvent.SetStatus -> { pickerState = UserStatusPickerState.Hidden - coroutineScope.launch { matrixClient.setUserStatus(event.status) } + coroutineScope.launch { + updateStatusAction.runUpdatingState { matrixClient.setUserStatus(event.status) } + } } UserStatusEvent.ClearStatus -> { pickerState = UserStatusPickerState.Hidden - coroutineScope.launch { matrixClient.clearUserStatus() } + coroutineScope.launch { + updateStatusAction.runUpdatingState { matrixClient.clearUserStatus() } + } } UserStatusEvent.OpenCustomInput -> { val raw = userProfile.rawStatus @@ -93,6 +100,7 @@ class UserStatusPresenter( displayedStatus = userProfile.displayedStatus, rawStatus = userProfile.rawStatus, pickerState = effectivePickerState, + updateStatusAction = updateStatusAction.value, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt index fa225ae277..6189ee6dd0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusState.kt @@ -11,6 +11,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.emoji.api.picker.EmojiPickerState import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus @@ -19,6 +20,7 @@ data class UserStatusState( val displayedStatus: DisplayedStatus?, val rawStatus: UserStatus?, val pickerState: UserStatusPickerState, + val updateStatusAction: AsyncAction, val eventSink: (UserStatusEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt index 13a1d31743..6b198183a7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.userstatus import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus @@ -44,10 +45,12 @@ fun aUserStatusState( displayedStatus: DisplayedStatus? = null, rawStatus: UserStatus? = null, pickerState: UserStatusPickerState = UserStatusPickerState.Hidden, + updateStatusAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (UserStatusEvent) -> Unit = {}, ) = UserStatusState( displayedStatus = displayedStatus, rawStatus = rawStatus, pickerState = pickerState, + updateStatusAction = updateStatusAction, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt index f145ef76ca..19cc43d3d7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusView.kt @@ -130,7 +130,7 @@ private fun EmptyStatusRow( modifier: Modifier = Modifier, ) { ListItem( - headlineContent = { + content = { Text( text = stringResource(R.string.screen_settings_user_status_placeholder), modifier = Modifier.padding(vertical = 16.dp), @@ -153,7 +153,7 @@ private fun CurrentStatusRow( is DisplayedStatus.InCall -> "🎧" to stringResource(CommonStrings.common_on_a_call) } ListItem( - headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, + content = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) }, leadingContent = ListItemContent.Custom { EmojiText(emoji) }, trailingContent = ListItemContent.Custom({ IconButton( @@ -209,7 +209,7 @@ private fun UserStatusPickerBottomSheet( val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label) val isSelected = currentRawStatus == predefinedUserStatus ListItem( - headlineContent = { Text(text = label) }, + content = { Text(text = label) }, leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) }, trailingContent = if (isSelected) { ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary) @@ -224,7 +224,7 @@ private fun UserStatusPickerBottomSheet( ) } ListItem( - headlineContent = { + content = { Text(text = stringResource(R.string.screen_settings_user_status_custom)) }, leadingContent = ListItemContent.Custom { EmojiText("✏️") }, @@ -261,7 +261,7 @@ private fun CustomStatusInputRow( val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } ListItem( - headlineContent = { + content = { TextField( modifier = Modifier.focusRequester(focusRequester), state = textFieldState, diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml index e35cf871a1..31add7855a 100644 --- a/features/preferences/impl/src/main/res/values-de/translations.xml +++ b/features/preferences/impl/src/main/res/values-de/translations.xml @@ -11,6 +11,12 @@ "Avatare in Chateinladungen ausblenden" "Medienvorschau im Nachrichtenverlauf ausblenden" "Labs" + "Die Entfernung, die du zurücklegen musst, um ein Update auszulösen." + "Live-Standort-Updates" + + "Alle %1$d Meter" + "Alle %1$d Meter" + "Lade Fotos und Videos schneller hoch und reduziere den Datenverbrauch" "Optimiere die Medienqualität" "Moderation und Sicherheit" diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml index f4a9616321..67afac50a1 100644 --- a/features/preferences/impl/src/main/res/values-hu/translations.xml +++ b/features/preferences/impl/src/main/res/values-hu/translations.xml @@ -101,6 +101,14 @@ Ha folytatja, egyes beállítások megváltozhatnak." "rendszerbeállításokat" "A rendszerértesítések ki vannak kapcsolva" "Értesítések" + "Távol" + "Rögtön visszajövök" + "Egyedi…" + "Egyéni állapot…" + "Feladatra koncentrál" + "Értekezleten" + "Úton" + "Mi az állapota?" "Fekete" "Sötét" "Világos" diff --git a/features/preferences/impl/src/main/res/values-pl/translations.xml b/features/preferences/impl/src/main/res/values-pl/translations.xml index fb1750e056..93c02d9f95 100644 --- a/features/preferences/impl/src/main/res/values-pl/translations.xml +++ b/features/preferences/impl/src/main/res/values-pl/translations.xml @@ -33,15 +33,15 @@ "Dostawca powiadomień push" "Wyłącz edytor tekstu bogatego, aby pisać tekst Markdown ręcznie." "Potwierdzenia odczytania" - "Gdy wyłączona, Twoje potwierdzenia odczytania nie zostaną wysłane. Potwierdzenia od innych wciąż będą odbierane." + "Po wyłączeniu potwierdzenia odczytu nie będą wysyłane. Nadal będziesz otrzymywać potwierdzenia od innych użytkowników." "Udostępnij obecność" - "Gdy wyłączona, nie będziesz mógł wysyłać lub odbierać potwierdzeń odczytu ani powiadomień pisania." + "Po wyłączeniu nie będzie można wysyłać ani odbierać potwierdzeń odczytu i powiadomień o pisaniu." "Zawsze ukrywaj" "Zawsze pokazuj" "W pokojach prywatnych" "Ukryte media można zawsze wyświetlić, dotykając ich" "Pokaż media na osi czasu" - "Włącz opcję, aby wyświetlić źródło wiadomości na osi czasu." + "Włącz, aby móc wyświetlać źródło wiadomości na osi czasu." "Nie blokujesz żadnych użytkowników" "Odblokuj" "Będziesz mógł ponownie zobaczyć wszystkie wiadomości od tego użytkownika." diff --git a/features/preferences/impl/src/main/res/values-ro/translations.xml b/features/preferences/impl/src/main/res/values-ro/translations.xml index 69eddf05f5..9c261d75fb 100644 --- a/features/preferences/impl/src/main/res/values-ro/translations.xml +++ b/features/preferences/impl/src/main/res/values-ro/translations.xml @@ -59,6 +59,7 @@ "Doriți experiențe noi?" "Laboratoare" "Setări adiționale" + "Ton de apel" "Apeluri audio și video" "Nepotrivire de configurație" "Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit. @@ -79,14 +80,38 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate.""Invitații" "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere." "Mențiuni" + "Alegeți un alt sunet…" + "Se utilizează în prezent %1$s" + "Sunet de mesaj" + "Sunet de mesaj" "Toate" "Mențiuni" "Anunță-mă pentru" "Anunțați-mă pentru @room" + "Personalizat" + "Sunet personalizat…" + "Eroare la ștergerea fișierului" + "Element Implicit" + "Element Fade" + "Eroare la importul fișierului" + "A fost intâmpinată o problemă la previzualizarea sunetului de alertă" + "Sunet" + "Ignorați eroarea privind sunetul de alertă" + "A fost întâmpinată o problemă la setarea sunetului de alertă" + "Silențios" + "Implicit sistem" "Pentru a primi notificări, vă rugăm să vă schimbați %1$s." "Setări de sistem" "Notificările de sistem sunt dezactivate" "Notificări" + "Plecat" + "Revin imediat" + "Personalizat…" + "Status personalizat…" + "Timp de concentrare" + "Într-o ședință" + "Pe drum" + "Care este statusul dumneavoastră?" "Negru" "Întunecat" "Deschis" diff --git a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml index 03892ce980..e9e973fde8 100644 --- a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml @@ -11,6 +11,13 @@ "在聊天室邀請請求中隱藏大頭貼" "在時間軸中隱藏媒體預覽" "實驗室" + "觸發更新所需的移動距離。" + "請確保已為此應用程式啟用「精確位置」功能。若要變更此權限,請前往 %1$s。" + "應用程式設定" + "即時位置更新" + + "每 %1$d 公尺" + "上傳照片與影片更快且減少資料使用量" "最佳化媒體品質" "管理與安全" @@ -50,6 +57,7 @@ "想不想來點新花樣?" "實驗室" "其他設定" + "通話鈴聲" "音訊與視訊通話" "組態錯誤" "我們簡化了通知設定,讓選項更容易搜尋。您過去選擇的某些自訂設定將不會在此顯示,但它們仍有作用。 @@ -68,14 +76,39 @@ "邀請" "您的家伺服器在加密聊天室中不支援此選項,可能無法收到部份聊天室的通知。" "提及" + "選擇其他音效⋯⋯" + "目前正在使用 %1$s" + "訊息音效" + "訊息音效" "全部" "提及" "通知我" "於 @room 通知我" + "自訂" + "自訂音效⋯⋯" + "刪除檔案時發生錯誤" + "Element 預設值" + "Element Fade" + "匯入檔案時發生錯誤" + "預覽警示音時發生問題" + "音效" + "忽略警示音錯誤" + "設定警示音時發生問題" + "靜音" + "系統預設值" "要收到通知,請變更您的 %1$s。" "系統設定" "已關閉系統通知" "通知" + "離開" + "稍後回來" + "自訂⋯⋯" + "自訂狀態⋯⋯" + "專注時間" + "在會議中" + "在路上" + "您的狀態如何?" + "黑色" "深色" "淺色" "系統" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt index 467ace4675..58e0cf467f 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt @@ -13,8 +13,10 @@ package io.element.android.features.preferences.impl.blockedusers import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.architecture.AsyncAction @@ -57,6 +59,20 @@ class BlockedUserViewTest : RobolectricTest() { eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) } + @Test + fun `long clicking on a user emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + val userList = aMatrixUserList() + setBlockedUsersView( + aBlockedUsersState( + blockedUsers = userList, + eventSink = eventsRecorder + ), + ) + onNodeWithText(userList.first().displayName.orEmpty()).performTouchInput { longClick() } + eventsRecorder.assertSingle(BlockedUsersEvents.CopyToClipboard(userList.first().userId)) + } + @Test fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt index d3ac29df9a..4e0164b2a0 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt @@ -12,7 +12,9 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService @@ -21,6 +23,7 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -169,6 +172,29 @@ class BlockedUsersPresenterTest { } } + @Test + fun `present - copy user id to clipboard`() = runTest { + val clipboardHelper = FakeClipboardHelper() + val matrixClient = FakeMatrixClient( + ignoredUsersFlow = MutableStateFlow(persistentListOf(A_USER_ID)) + ) + val presenter = aBlockedUsersPresenter( + matrixClient = matrixClient, + clipboardHelper = clipboardHelper, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.snackbarMessage).isNull() + initialState.eventSink(BlockedUsersEvents.CopyToClipboard(A_USER_ID)) + + assertThat(clipboardHelper.clipboardContents).isEqualTo(A_USER_ID.value) + assertThat(awaitItem().snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - confirm unblock without a pending blocked user does nothing`() = runTest { val presenter = aBlockedUsersPresenter() @@ -183,8 +209,12 @@ class BlockedUsersPresenterTest { private fun aBlockedUsersPresenter( matrixClient: FakeMatrixClient = FakeMatrixClient(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), + snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), ) = BlockedUsersPresenter( matrixClient = matrixClient, featureFlagService = featureFlagService, + clipboardHelper = clipboardHelper, + snackbarDispatcher = snackbarDispatcher, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index 52d1247668..bb688f498d 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -25,7 +25,9 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes +import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -57,6 +59,7 @@ class DeveloperSettingsPresenterTest { assertThat(state.cacheSize).isEqualTo(AsyncData.Uninitialized) assertThat(state.isEnterpriseBuild).isFalse() assertThat(state.showColorPicker).isFalse() + assertThat(state.deviceId).isEqualTo(A_DEVICE_ID) } awaitItem().also { state -> assertThat(state.cacheSize.isLoading()).isTrue() @@ -169,6 +172,7 @@ class DeveloperSettingsPresenterTest { private fun createDeveloperSettingsPresenter( sessionId: SessionId = A_SESSION_ID, + deviceId: DeviceId = A_DEVICE_ID, cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), enterpriseService: EnterpriseService = FakeEnterpriseService(), @@ -179,6 +183,7 @@ class DeveloperSettingsPresenterTest { return DeveloperSettingsPresenter( appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() }, sessionId = sessionId, + deviceId = deviceId, computeCacheSizeUseCase = cacheSizeUseCase, clearCacheUseCase = clearCacheUseCase, enterpriseService = enterpriseService, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index adae2ca816..a16ad77d48 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.tests.testutils.EnsureNeverCalled @@ -41,7 +42,7 @@ class DeveloperSettingsViewTest : RobolectricTest() { } } - @Config(qualifiers = "h2000dp") + @Config(qualifiers = "h2400dp") @Test fun `clicking on push history notification invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -80,7 +81,7 @@ class DeveloperSettingsViewTest : RobolectricTest() { eventSink = eventsRecorder ), ) - onNodeWithText("Clear cache").performClick() + onNodeWithText("Clear cache").performScrollTo().performClick() eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt index 3d2cc85e54..f4dad80e8b 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -53,6 +54,8 @@ class AppDeveloperSettingsPresenterTest { assertThat(state.rageshakeState.isSupported).isTrue() assertThat(state.rageshakeState.sensitivity).isEqualTo(0.3f) assertThat(state.tracingLogLevel).isEqualTo(AsyncData.Uninitialized) + assertThat(state.gitBranch).isEqualTo("feature/awesome-feature") + assertThat(state.gitSha).isEqualTo("1234567890") } awaitItem().also { state -> assertThat(state.features).isNotEmpty() @@ -145,6 +148,10 @@ class AppDeveloperSettingsPresenterTest { featureFlagService = featureFlagService, rageshakePresenter = { aRageshakePreferencesState() }, appPreferencesStore = preferencesStore, + buildMeta = aBuildMeta( + gitRevision = "1234567890", + gitBranchName = "feature/awesome-feature" + ) ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt index c00dc7689b..0a05c0e3a3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/VersionFormatterTest.kt @@ -16,7 +16,7 @@ import org.junit.Test class VersionFormatterTest { @Test - fun `version formatter should return simplified version for main branch`() = runTest { + fun `version formatter should return formatted version`() = runTest { val sut = DefaultVersionFormatter( stringProvider = FakeStringProvider(defaultResult = VERSION), buildMeta = aBuildMeta( @@ -28,21 +28,7 @@ class VersionFormatterTest { assertThat(sut.get()).isEqualTo("${VERSION}versionName, 123") } - @Test - fun `version formatter should return simplified version for other branch`() = runTest { - val sut = DefaultVersionFormatter( - stringProvider = FakeStringProvider(defaultResult = VERSION), - buildMeta = aBuildMeta( - versionName = "versionName", - versionCode = 123, - gitBranchName = "branch", - gitRevision = "1234567890", - ) - ) - assertThat(sut.get()).isEqualTo("${VERSION}versionName, 123\nbranch (1234567890)") - } - companion object { - const val VERSION = "version" + const val VERSION = "version: " } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt index a09afdeb6f..e9897259ae 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/userstatus/UserStatusPresenterTest.kt @@ -8,9 +8,11 @@ package io.element.android.features.preferences.impl.userstatus import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.UserStatus +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.test import kotlinx.coroutines.test.runTest @@ -61,6 +63,24 @@ class UserStatusPresenterTest { val newState = awaitItem() assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) + assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(client.setUserStatusCalled).isTrue() + } + } + + @Test + fun `Set event failure surfaces a Failure action`() = runTest { + val client = FakeMatrixClient().apply { + setUserStatusResult = Result.failure(AN_EXCEPTION) + } + createPresenter(client).test { + val status = UserStatus(emoji = "💬", text = "In a meeting") + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) + val newState = awaitItem() + assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + // On failure the SDK does not emit a new profile, so nothing is displayed. + assertThat(newState.displayedStatus).isNull() + assertThat(newState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(client.setUserStatusCalled).isTrue() } } @@ -121,6 +141,25 @@ class UserStatusPresenterTest { val clearedState = awaitItem() assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) assertThat(clearedState.displayedStatus).isNull() + assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(client.clearUserStatusCalled).isTrue() + } + } + + @Test + fun `Clear event failure surfaces a Failure action`() = runTest { + val client = FakeMatrixClient().apply { + clearUserStatusResult = Result.failure(AN_EXCEPTION) + } + createPresenter(client).test { + val status = UserStatus(emoji = "☕", text = "Be right back") + awaitItem().eventSink(UserStatusEvent.SetStatus(status)) + awaitItem().eventSink(UserStatusEvent.ClearStatus) + val clearedState = awaitItem() + assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden) + // Clear failed, so the previously set status is still displayed. + assertThat(clearedState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status)) + assertThat(clearedState.updateStatusAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(client.clearUserStatusCalled).isTrue() } } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt index bc72e7021b..c4205c1575 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/RageshakeFeatureAvailability.kt @@ -10,6 +10,10 @@ package io.element.android.features.rageshake.api import kotlinx.coroutines.flow.Flow +/** + * Tells whether bug reporting is available, which an enterprise deployment can turn off. + */ fun interface RageshakeFeatureAvailability { + /** Whether the rageshake and bug report entry points should be offered to the user. */ fun isAvailable(): Flow } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt index f8eee31675..a7b2961ab6 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/logs/LogFilesRemover.kt @@ -10,6 +10,9 @@ package io.element.android.features.rageshake.api.logs import java.io.File +/** + * Deletes the log files the app writes, used to keep them from growing without bound and to clear them on request. + */ interface LogFilesRemover { /** * Perform the log files removal. diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt index 7ea35308e9..a227d0ffcf 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/preferences/RageshakePreferencesView.kt @@ -56,7 +56,7 @@ fun RageshakePreferencesView( ) } else { ListItem( - headlineContent = { + content = { Text("Rageshaking is not supported by your device") }, ) diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt index 631a51a686..c1611f30b5 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -10,6 +10,11 @@ package io.element.android.features.rageshake.api.reporter import java.io.File +/** + * Uploads bug reports, including the log files the app has been writing. + * + * Progress and the outcome are reported through the listener passed to [sendBugReport] rather than by returning or throwing. + */ interface BugReporter { /** * Send a bug report. @@ -35,17 +40,21 @@ interface BugReporter { ) /** - * Provide the log directory. + * Provide the log directory, i.e. where the rotating log files are written. */ fun logDirectory(): File /** - * Set the current tracing log level. + * Set the current tracing log level, so it can be reported alongside the bug. + * + * @param logLevel the log level currently in effect. */ fun setCurrentTracingLogLevel(logLevel: String) /** * Save the logcat. + * + * @return the file the logcat was written to, or `null` when it could not be captured. */ fun saveLogCat(): File? } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt index 36b407a669..fede373627 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -101,7 +101,7 @@ fun BugReportView( Spacer(modifier = Modifier.height(16.dp)) PreferenceDivider() ListItem( - headlineContent = { + content = { Text(stringResource(id = R.string.screen_bug_report_view_logs)) }, enabled = isFormEnabled, diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 735bb1cea0..c1000784fc 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -152,7 +152,7 @@ class DefaultBugReporter( append("\n\n\n\n--------------------------------- crash call stack ---------------------------------\n") append(crashCallStack) } - } + }.truncateDescription() val gzippedFiles = mutableListOf() var filesTooBig = emptyList() @@ -185,6 +185,7 @@ class DefaultBugReporter( .addFormDataPart("device", Build.MODEL.trim()) .addFormDataPart("locale", Locale.getDefault().toString()) .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) + .addFormDataPart("sha", buildMeta.gitRevision) .addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) .addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME)) .addFormDataPart("app_id", buildMeta.applicationId) @@ -493,4 +494,13 @@ class DefaultBugReporter( private fun countLogLines(file: File): Int { return file.reader().useLines { it.count() } } + + private fun String.truncateDescription(): String { + return if (length <= RageshakeConfig.MAX_DESCRIPTION_SIZE) { + this + } else { + take(RageshakeConfig.MAX_DESCRIPTION_SIZE) + + "\n\n--------------------------------- truncated, see the attached logs ---------------------------------" + } + } } diff --git a/features/rageshake/impl/src/main/res/values-ro/translations.xml b/features/rageshake/impl/src/main/res/values-ro/translations.xml index f342ffd4c5..cdcfbdfbc8 100644 --- a/features/rageshake/impl/src/main/res/values-ro/translations.xml +++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml @@ -8,6 +8,8 @@ "Descrieți problema…" "Dacă posibil, vă rugăm să scrieți descrierea în engleză." "Descrierea este prea scurtă, vă rugăm să oferiți mai multe detalii despre ceea ce s-a întâmplat. Vă mulțumim!" + "Puteți introduce numărul unui tichet GitHub asociat, dacă există." + "Tichet GitHub" "Trimiteți log-uri" "Permiteți log-uri" "Jurnalele dumneavoastră sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă." diff --git a/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml b/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml index 10252ab699..2c5c310370 100644 --- a/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/rageshake/impl/src/main/res/values-zh-rTW/translations.xml @@ -8,6 +8,8 @@ "描述問題…" "如果方便的話,請使用英文。" "您的描述太短了,請提供更多細節。謝謝!" + "若有相關的 GitHub 議題編號,請在此處輸入。" + "GitHub 議題" "傳送當機紀錄" "提供日誌" "您的紀錄檔太大了,無法包含在此報告中,請透過其他方式傳送給我們。" diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index 4f666d374b..7bb2add0e5 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -309,6 +309,68 @@ class DefaultBugReporterTest : RobolectricTest() { assertThat(foundValues["label"]).isEqualTo("crash") } + @Test + fun `test sendBugReport truncates a description which is too long`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + val hugeCrashCallStack = "a".repeat(RageshakeConfig.MAX_DESCRIPTION_SIZE * 2) + val sut = createDefaultBugReporter( + server = server, + crashDataStore = FakeCrashDataStore(hugeCrashCallStack, true), + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = NoopBugReporterListener(), + ) + val request = server.takeRequest() + + val text = collectValuesFromFormData(request)["text"]!! + assertThat(text).startsWith("a bug occurred") + assertThat(text).contains("truncated") + assertThat(text.length).isAtMost(RageshakeConfig.MAX_DESCRIPTION_SIZE + TRUNCATION_MARKER_MAX_SIZE) + assertThat(text.length).isLessThan(GITHUB_MAX_ISSUE_BODY_SIZE) + server.shutdown() + } + + @Test + fun `test sendBugReport does not truncate a description which fits`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + val sut = createDefaultBugReporter( + server = server, + crashDataStore = FakeCrashDataStore("I did crash", true), + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = NoopBugReporterListener(), + ) + val request = server.takeRequest() + + val text = collectValuesFromFormData(request)["text"]!! + assertThat(text).startsWith("a bug occurred") + assertThat(text).endsWith("I did crash") + assertThat(text).doesNotContain("truncated") + server.shutdown() + } + private fun collectValuesFromFormData(request: RecordedRequest): HashMap { val boundary = request.headers["Content-Type"]!!.split("=").last() val foundValues = HashMap() @@ -534,6 +596,8 @@ class DefaultBugReporterTest : RobolectricTest() { } companion object { - private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 17 + private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 18 + private const val GITHUB_MAX_ISSUE_BODY_SIZE = 65_536 + private const val TRUNCATION_MARKER_MAX_SIZE = 200 } } diff --git a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt index 4ab1a51336..a754825dcc 100644 --- a/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt +++ b/features/reportroom/impl/src/main/kotlin/io/element/android/features/reportroom/impl/ReportRoomView.kt @@ -108,7 +108,7 @@ fun ReportRoomView( ListItem( modifier = Modifier.padding(end = 8.dp), - headlineContent = { + content = { Text(text = stringResource(CommonStrings.action_leave_room)) }, onClick = { diff --git a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt index 7cfdfefae0..4f823387f3 100644 --- a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt +++ b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/ChangeRoomMemberRolesEntryPoint.kt @@ -22,8 +22,18 @@ fun interface ChangeRoomMemberRolesEntryPoint : FeatureEntryPoint { listType: ChangeRoomMemberRolesListType, ): Node + /** + * Lets a caller outside this feature wait for the role change to finish, which is needed when leaving a room depends on it. + */ interface NodeProxy { + /** The room whose member roles are being changed. */ val roomId: RoomId + + /** + * Suspends until the user finishes or abandons the flow. + * + * @return true when roles were actually changed, false when the user backed out. + */ suspend fun waitForCompletion(): Boolean } } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt index 07d2711cc6..3cf29c18cf 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt @@ -62,7 +62,7 @@ fun RolesAndPermissionsView( stringResource(R.string.screen_room_roles_and_permissions_admins) } ListItem( - headlineContent = { Text(adminsTitle) }, + content = { Text(adminsTitle) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), trailingContent = state.adminCount?.let { adminCount -> ListItemContent.Text("$adminCount") @@ -70,7 +70,7 @@ fun RolesAndPermissionsView( onClick = { rolesAndPermissionsNavigator.openAdminList() }, ) ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) }, + content = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), trailingContent = state.moderatorCount?.let { moderationCount -> ListItemContent.Text("$moderationCount") @@ -79,20 +79,20 @@ fun RolesAndPermissionsView( ) if (state.canSelfDemote) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) }, + content = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) }, onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit())) ) } HorizontalDivider() ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_permissions_header)) }, + content = { Text(stringResource(R.string.screen_room_roles_and_permissions_permissions_header)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())), onClick = { rolesAndPermissionsNavigator.openEditPermissions() }, ) HorizontalDivider() ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_reset)) }, + content = { Text(stringResource(R.string.screen_room_roles_and_permissions_reset)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), onClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, style = ListItemStyle.Destructive, @@ -173,7 +173,7 @@ private fun ChangeOwnRoleBottomSheet( ) for (demoteAction in availableDemoteActions) { ListItem( - headlineContent = { Text(stringResource(demoteAction.titleRes)) }, + content = { Text(stringResource(demoteAction.titleRes)) }, onClick = { sheetState.hide(coroutineScope) { eventSink(RolesAndPermissionsEvents.DemoteSelfTo(demoteAction.role)) @@ -183,7 +183,7 @@ private fun ChangeOwnRoleBottomSheet( ) } ListItem( - headlineContent = { Text(stringResource(CommonStrings.action_cancel)) }, + content = { Text(stringResource(CommonStrings.action_cancel)) }, onClick = ::dismiss, ) } diff --git a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml index e9933c4819..409c112ad1 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml @@ -6,6 +6,7 @@ "移除訊息" "成員" "邀請夥伴" + "分享即時位置" "管理空間" "管理聊天室" "管理成員" diff --git a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt index 15e320c8f8..7763933a29 100644 --- a/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt +++ b/features/roomcall/impl/src/main/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenter.kt @@ -60,10 +60,13 @@ class RoomCallStatePresenter( isUserLocallyInTheCall = isUserLocallyInTheCall, isAudioCall = roomInfo.activeCallIntentConsensus.isAudio(), ) - else -> RoomCallState.StandBy( - canStartCall = canJoinCall, - isDM = roomInfo.isDm - ) + else -> { + val isEmptyDm = roomInfo.isDm && roomInfo.activeMembersCount <= 1 + RoomCallState.StandBy( + canStartCall = canJoinCall && !isEmptyDm, + isDM = roomInfo.isDm + ) + } } } } diff --git a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt index 0612adbea1..9a4c3b0af0 100644 --- a/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt +++ b/features/roomcall/impl/src/test/kotlin/io/element/android/features/roomcall/impl/RoomCallStatePresenterTest.kt @@ -49,6 +49,42 @@ class RoomCallStatePresenterTest { } } + @Test + fun `present - a DM with no other member cannot start a call`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + initialRoomInfo = aRoomInfo(isDm = true, activeMembersCount = 1, joinedMembersCount = 1), + roomPermissions = roomPermissions(true), + ) + ) + val presenter = createRoomCallStatePresenter(joinedRoom = room) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo( + RoomCallState.StandBy( + canStartCall = false, + isDM = true + ) + ) + } + } + + @Test + fun `present - a DM becomes callable once the other member is active`() = runTest { + val baseRoom = FakeBaseRoom( + initialRoomInfo = aRoomInfo(isDm = true, activeMembersCount = 1, joinedMembersCount = 1), + roomPermissions = roomPermissions(true), + ) + val room = FakeJoinedRoom(baseRoom = baseRoom) + val presenter = createRoomCallStatePresenter(joinedRoom = room) + presenter.test { + skipItems(1) + assertThat(awaitItem()).isEqualTo(RoomCallState.StandBy(canStartCall = false, isDM = true)) + baseRoom.givenRoomInfo(aRoomInfo(isDm = true, activeMembersCount = 2, joinedMembersCount = 2)) + assertThat(awaitItem()).isEqualTo(RoomCallState.StandBy(canStartCall = true, isDM = true)) + } + } + @Test fun `present - element call not available`() = runTest { val room = FakeJoinedRoom( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 412006feae..96dc3e25ba 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -202,7 +202,7 @@ fun RoomDetailsView( PreferenceCategory { if (state.hasNewContent) { ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_roomlist_mark_as_read), style = MaterialTheme.typography.bodyLarge, @@ -225,7 +225,7 @@ fun RoomDetailsView( ) } else { ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_roomlist_mark_as_unread), ) @@ -268,7 +268,7 @@ fun RoomDetailsView( } if (state.displayRolesAndPermissionsSettings) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) }, + content = { Text(stringResource(R.string.screen_room_details_roles_and_permissions)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), onClick = openAdminSettings, ) @@ -317,7 +317,7 @@ fun RoomDetailsView( ) { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), - headlineContent = { + content = { Text(stringResource(id = R.string.screen_room_details_encryption_enabled_title)) }, supportingContent = { @@ -345,7 +345,7 @@ fun RoomDetailsView( @Composable private fun KnockRequestsItem(knockRequestsCount: Int?, onKnockRequestsClick: () -> Unit) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_details_requests_to_join_title)) }, + content = { Text(stringResource(R.string.screen_room_details_requests_to_join_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.AskToJoin())), trailingContent = if (knockRequestsCount == null || knockRequestsCount == 0) { null @@ -656,7 +656,7 @@ private fun TopicSection( if (roomTopic is RoomTopicState.CanAddTopic) { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())), - headlineContent = { + content = { Text(stringResource(id = R.string.screen_room_details_add_topic_title)) }, onClick = { @@ -687,7 +687,7 @@ private fun NotificationItem( stringResource(R.string.screen_room_details_notification_mode_custom) } ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) }, + content = { Text(text = stringResource(R.string.screen_room_details_notification_title)) }, supportingContent = { Text(text = subtitle) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())), onClick = openRoomNotificationSettings, @@ -700,7 +700,7 @@ private fun SecurityAndPrivacyItem( modifier: Modifier = Modifier, ) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_details_security_and_privacy_title)) }, + content = { Text(stringResource(R.string.screen_room_details_security_and_privacy_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onClick, modifier = modifier, @@ -732,7 +732,7 @@ private fun ProfileItem( ) { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), - headlineContent = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) }, + content = { Text(stringResource(id = R.string.screen_room_details_profile_row_title)) }, trailingContent = when (verificationState) { UserProfileVerificationState.VERIFIED -> ListItemContent.Icon( iconSource = IconSource.Vector(CompoundIcons.Verified()), @@ -755,7 +755,7 @@ private fun MembersItem( openRoomMemberList: () -> Unit, ) { ListItem( - headlineContent = { Text(stringResource(CommonStrings.common_people)) }, + content = { Text(stringResource(CommonStrings.common_people)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())), trailingContent = if (hasVerificationViolations) { ListItemContent.Icon( @@ -774,7 +774,7 @@ private fun InviteItem( onClick: () -> Unit, ) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_details_invite_title)) }, + content = { Text(stringResource(R.string.screen_room_details_invite_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())), onClick = onClick, ) @@ -787,7 +787,7 @@ private fun PinnedMessagesItem( ) { val analyticsService = LocalAnalyticsService.current ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) }, + content = { Text(stringResource(R.string.screen_room_details_pinned_events_row_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Pin())), trailingContent = if (pinnedMessagesCount == null) { @@ -809,7 +809,7 @@ private fun PollsItem( openPollHistory: () -> Unit, ) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_polls_history_title)) }, + content = { Text(stringResource(R.string.screen_polls_history_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())), onClick = openPollHistory, ) @@ -820,7 +820,7 @@ private fun MediaGalleryItem( onClick: () -> Unit, ) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) }, + content = { Text(stringResource(R.string.screen_room_details_media_gallery_title)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), onClick = onClick, ) @@ -840,7 +840,7 @@ private fun OtherActionsSection( } if (canReportRoom) { ListItem( - headlineContent = { + content = { Text(stringResource(CommonStrings.action_report_room)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), @@ -849,7 +849,7 @@ private fun OtherActionsSection( ) } ListItem( - headlineContent = { + content = { Text(stringResource(CommonStrings.action_leave_room)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())), @@ -868,7 +868,7 @@ private fun DebugInfoSection( PreferenceCategory { val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) ListItem( - headlineContent = { + content = { Text("Internal room ID") }, supportingContent = { @@ -888,7 +888,7 @@ private fun DebugInfoSection( }, ) ListItem( - headlineContent = { + content = { Text("Room version") }, supportingContent = { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index f47f12f446..674dd9aad9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList @@ -58,6 +59,7 @@ class RoomMemberListPresenter( override fun present(): RoomMemberListState { val searchQuery = rememberTextFieldState() val membersState by room.membersStateFlow.collectAsState() + val roomInfo by room.roomInfoFlow.collectAsState() val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() } val roomModerationState = roomMembersModerationPresenter.present() @@ -72,13 +74,18 @@ class RoomMemberListPresenter( var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) } var roomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } var filteredRoomMembers: AsyncData by remember { mutableStateOf(AsyncData.Loading()) } + val activeCallParticipants = roomInfo.activeRoomCallParticipants + + val comparator = remember(activeCallParticipants) { + roomMemberListComparator(activeCallParticipants) + } // Update the room members when the screen is loaded LaunchedEffect(Unit) { room.updateMembers() } - LaunchedEffect(membersState, roomMemberIdentityStates) { + LaunchedEffect(membersState, roomMemberIdentityStates, activeCallParticipants) { if (membersState is RoomMembersState.Unknown) { return@LaunchedEffect } @@ -90,23 +97,23 @@ class RoomMemberListPresenter( } withContext(coroutineDispatchers.io) { val members = membersState.roomMembers().orEmpty().groupBy { it.membership } - val info = room.info() - if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) { + if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < roomInfo.joinedMembersCount / 2) { // Don't display initial room member list if we have less than half of the joined members: - // This result will come from the timeline loading membership events and it'll be wrong. + // This result will come from the timeline loading membership events, and it'll be wrong. return@withContext } val result = RoomMembers( invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()) - .map { it.withIdentityState(roomMemberIdentityStates) } + .map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) } .toImmutableList(), joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) - .sortedWith(powerLevelRoomMemberComparator) - .map { it.withIdentityState(roomMemberIdentityStates) } + .sortedWith(comparator) + // .sortedWith(powerLevelRoomMemberComparator) + .map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) } .toImmutableList(), banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()) .sortedBy { it.userId.value } - .map { it.withIdentityState(roomMemberIdentityStates) } + .map { it.withIdentityState(roomMemberIdentityStates, activeCallParticipants) } .toImmutableList(), ) roomMembers = if (membersState is RoomMembersState.Pending) { @@ -150,12 +157,29 @@ class RoomMemberListPresenter( return state } - private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap): RoomMemberWithIdentityState { + private suspend fun RoomMember.withIdentityState( + identityStates: ImmutableMap, + activeCallParticipants: ImmutableList + ): RoomMemberWithIdentityState { return if (room.info().isEncrypted != true) { - RoomMemberWithIdentityState(this, null) + RoomMemberWithIdentityState(this, null, userId in activeCallParticipants) } else { val identityState = identityStates[userId] ?: encryptionService.getUserIdentity(userId, fallbackToServer = false).getOrNull() - RoomMemberWithIdentityState(this, identityState) + RoomMemberWithIdentityState(this, identityState, userId in activeCallParticipants) } } + + private fun roomMemberListComparator( + activeCallParticipants: List, + ): Comparator = + Comparator { a, b -> + val aInCall = a.userId in activeCallParticipants + val bInCall = b.userId in activeCallParticipants + + when { + aInCall && !bInCall -> -1 + !aInCall && bInCall -> 1 + else -> powerLevelRoomMemberComparator.compare(a, b) + } + } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index 6d52db6c87..7b6744ef0b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -66,4 +66,5 @@ data class RoomMembers( data class RoomMemberWithIdentityState( val roomMember: RoomMember, val identityState: IdentityState?, + val isInCall: Boolean ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index 472a185076..6b1971fdae 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -78,7 +78,7 @@ private fun aLoadedRoomMembers() = AsyncData.Success( joined = persistentListOf( anAlice().withIdentity(identityState = IdentityState.Verified), aBob().withIdentity(identityState = IdentityState.PinViolation), - aCarol().withIdentity(), + aCarol().withIdentity(isInCall = true), aDavid().withIdentity(), anEve().withIdentity(identityState = IdentityState.VerificationViolation) ), @@ -174,4 +174,8 @@ fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), USER_N fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), USER_NAME_MALLORY, membership = RoomMembershipState.BAN) -private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState) +private fun RoomMember.withIdentity(identityState: IdentityState? = null, isInCall: Boolean = false) = RoomMemberWithIdentityState( + this, + identityState, + isInCall +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index 659d8ed0e0..33ca61b570 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -338,6 +338,15 @@ private fun RoomMemberListItem( color = ElementTheme.colors.textSecondary, ) } + + if (roomMemberWithIdentity.isInCall) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.VideoCallSolid(), + contentDescription = stringResource(CommonStrings.action_video_call), + tint = ElementTheme.colors.iconSuccessPrimary + ) + } } } ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt index 2d5418afdc..eb695f1f34 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsItem.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import io.element.android.features.roomdetails.impl.R import io.element.android.libraries.matrix.api.room.RoomNotificationMode -import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -36,7 +35,7 @@ fun roomNotificationSettingsItems(): ImmutableList ) RoomNotificationMode.MUTE -> RoomNotificationSettingsItem( mode = it, - title = stringResource(CommonStrings.common_mute), + title = stringResource(R.string.screen_room_notification_settings_mode_mute), ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt index 8ff1be538f..cee23e6ba1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt @@ -40,7 +40,7 @@ fun RoomNotificationSettingsOption( ListItem( modifier = modifier, enabled = enabled, - headlineContent = { Text(title) }, + content = { Text(title) }, supportingContent = subtitle?.let { { Text(it) } }, trailingContent = ListItemContent.RadioButton(selected = isSelected), onClick = { onSelectOption(roomNotificationSettingsItem) }, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index 12bc258944..a8be2944a2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -34,7 +34,6 @@ import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithSt import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.RoomNotificationMode -import io.element.android.libraries.ui.strings.CommonStrings @Composable fun RoomNotificationSettingsView( @@ -118,7 +117,7 @@ private fun RoomSpecificNotificationSettingsView( RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> { stringResource(id = R.string.screen_room_notification_settings_mode_mentions_and_keywords) } - RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute) + RoomNotificationMode.MUTE -> stringResource(id = R.string.screen_room_notification_settings_mode_mute) } val displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer && state.defaultRoomNotificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index 3e4e9e773e..1721c9925c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -66,7 +66,7 @@ fun UserDefinedRoomNotificationSettingsView( } ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_notification_settings_edit_remove_setting)) }, + content = { Text(stringResource(R.string.screen_room_notification_settings_edit_remove_setting)) }, style = ListItemStyle.Destructive, onClick = { state.eventSink(RoomNotificationSettingsEvent.DeleteCustomNotification) diff --git a/features/roomdetails/impl/src/main/res/values/temporary.xml b/features/roomdetails/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..b3194f3323 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values/temporary.xml @@ -0,0 +1,10 @@ + + + + Mute + diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt index 48f0e791a3..0a88d2bf57 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt @@ -191,6 +191,38 @@ class RoomMemberListPresenterTest { awaitItem().eventSink(RoomMemberListEvent.RoomMemberSelected(anInvitedVictor())) } } + + @Test + fun `call participants are shown first in joined members`() = runTest { + val room = createFakeJoinedRoom().apply { + givenRoomInfo( + aRoomInfo( + joinedMembersCount = 5, + activeRoomCallParticipants = listOf( + aCarol().userId + ), + ) + ) + givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) + } + + val presenter = createPresenter(joinedRoom = room) + + presenter.test { + skipItems(1) + + val loadedState = awaitItem() + val joined = loadedState.filteredRoomMembers.dataOrNull()!!.joined + + assertThat(joined.first().roomMember.userId) + .isEqualTo(aCarol().userId) + + assertThat(joined.first().isInCall).isTrue() + + assertThat(joined.count { it.isInCall }).isEqualTo(1) + assertThat(joined.drop(1).all { !it.isInCall }).isTrue() + } + } } private fun createFakeJoinedRoom( diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt index 2238ff41df..d452ff4e66 100644 --- a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationEvents.kt @@ -10,6 +10,9 @@ package io.element.android.features.roommembermoderation.api import io.element.android.libraries.matrix.api.user.MatrixUser +/** + * Events sent to the moderation presenter through [RoomMemberModerationState.eventSink]. + */ interface RoomMemberModerationEvents { data class ShowActionsForUser(val user: MatrixUser) : RoomMemberModerationEvents data class ProcessAction(val action: ModerationAction, val targetUser: MatrixUser) : RoomMemberModerationEvents diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt index efc87d8a40..7696f826a1 100644 --- a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationRenderer.kt @@ -12,7 +12,18 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.element.android.libraries.matrix.api.user.MatrixUser +/** + * Renders the moderation action sheet and its confirmation dialogs; it draws nothing until the host screen asks for a member's actions. + */ interface RoomMemberModerationRenderer { + /** + * Draws whichever sheet or dialog the current state calls for. + * + * @param state the state produced by the moderation presenter. + * @param onSelectAction called when the user picks an action, so the host screen can navigate if that action needs its own screen. + * @param onAvatarClick called when the user taps the member's avatar, or `null` to make it non-interactive. + * @param modifier layout modifier for the container. + */ @Composable fun Render( state: RoomMemberModerationState, diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt index c9ee958f6f..9227c77edf 100644 --- a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt @@ -10,9 +10,15 @@ package io.element.android.features.roommembermoderation.api import androidx.compose.runtime.Immutable +/** + * State of the member moderation flow, which is the action sheet and the confirmation dialogs shown for a room member. + */ @Immutable interface RoomMemberModerationState { + /** Which moderation actions the current user is allowed to perform, so the UI can hide or disable the rest. */ val permissions: RoomMemberModerationPermissions + + /** Where the host screen sends its events to open the actions of a member or run one of them. */ val eventSink: (RoomMemberModerationEvents) -> Unit } diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt index 22f7439b2a..a5e0ac274f 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationView.kt @@ -301,7 +301,7 @@ private fun RoomMemberActionsBottomSheet( when (val action = actionState.action) { is ModerationAction.DisplayProfile -> { ListItem( - headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_member_user_info)) }, + content = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_member_user_info)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), onClick = { coroutineScope.launch { @@ -314,7 +314,7 @@ private fun RoomMemberActionsBottomSheet( } is ModerationAction.KickUser -> { ListItem( - headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_remove)) }, + content = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_remove)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Close())), style = ListItemStyle.Destructive, onClick = { @@ -328,7 +328,7 @@ private fun RoomMemberActionsBottomSheet( } is ModerationAction.BanUser -> { ListItem( - headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_ban)) }, + content = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_ban)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), style = ListItemStyle.Destructive, onClick = { @@ -342,7 +342,7 @@ private fun RoomMemberActionsBottomSheet( } is ModerationAction.UnbanUser -> { ListItem( - headlineContent = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_unban)) }, + content = { Text(stringResource(R.string.screen_bottom_sheet_manage_room_member_unban)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Restart())), style = ListItemStyle.Destructive, onClick = { diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt index 0aff81aafa..626a162152 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt @@ -55,7 +55,7 @@ fun SecureBackupRootView( snackbarHost = { SnackbarHost(snackbarHostState) }, ) { ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_chat_backup_key_backup_title), ) @@ -76,7 +76,7 @@ fun SecureBackupRootView( // Disable / Enable key storage ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_chat_backup_key_storage_toggle_title), ) @@ -137,7 +137,7 @@ fun SecureBackupRootView( RecoveryState.WAITING_FOR_SYNC -> Unit RecoveryState.DISABLED -> { ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_chat_backup_recovery_action_setup), ) @@ -161,7 +161,7 @@ fun SecureBackupRootView( } RecoveryState.ENABLED -> { ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_chat_backup_recovery_action_change), ) @@ -184,7 +184,7 @@ fun SecureBackupRootView( } RecoveryState.INCOMPLETE -> ListItem( - headlineContent = { + content = { Text( text = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm), ) diff --git a/features/securebackup/impl/src/main/res/values-de/translations.xml b/features/securebackup/impl/src/main/res/values-de/translations.xml index d53086a6d1..7fbcc1e4b4 100644 --- a/features/securebackup/impl/src/main/res/values-de/translations.xml +++ b/features/securebackup/impl/src/main/res/values-de/translations.xml @@ -8,10 +8,11 @@ "Schlüssel von diesem Gerät hochladen" "Schlüsselspeicherung zulassen" "Wiederherstellungsschlüssel ändern" - "Stelle deine kryptographische Identität und deinen Nachrichtenverlauf mit einem Wiederherstellungsschlüssel wieder her, falls du deine Geräte verloren hast." + "Von deinen Chats wird automatisch ein Ende-zu-Ende-verschlüsseltes Backup erstellt. Wenn du den Zugriff auf alle deine Geräte verlierst, brauchst du deinen Wiederherstellungsschlüssel, um dieses Backup wiederherzustellen und deine digitale Identität zu behalten." "Wiederherstellungsschlüssel eingeben" "Dein Schlüssel ist derzeit nicht synchronisiert." "Wiederherstellungsschlüssel einrichten" + "Von deinen Chats wird automatisch ein Ende-zu-Ende-verschlüsseltes Backup erstellt. Wenn du den Zugriff auf alle deine Geräte verlierst, brauchst du deinen Wiederherstellungsschlüssel, um dieses Backup wiederherzustellen und deine digitale Identität zu behalten." "Öffne " "%1$s" diff --git a/features/securebackup/impl/src/main/res/values-pl/translations.xml b/features/securebackup/impl/src/main/res/values-pl/translations.xml index fb1da3ca04..491d30d8c1 100644 --- a/features/securebackup/impl/src/main/res/values-pl/translations.xml +++ b/features/securebackup/impl/src/main/res/values-pl/translations.xml @@ -8,11 +8,11 @@ "Prześlij klucze z tego urządzenia" "Zezwól na magazynowanie kluczy" "Zmień klucz przywracania" - "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." + "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." "Wprowadź klucz przywracania" "Magazyn kluczy nie jest zsynchronizowany." "Uzyskaj klucz przywracania" - "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." + "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." "Otwórz %1$s na urządzeniu stacjonarnym" "Zaloguj się ponownie na swoje konto" "Gdy pojawi się prośba o weryfikację urządzenia, wybierz %1$s" diff --git a/features/securebackup/impl/src/main/res/values-ro/translations.xml b/features/securebackup/impl/src/main/res/values-ro/translations.xml index 2351a54923..c9060945c7 100644 --- a/features/securebackup/impl/src/main/res/values-ro/translations.xml +++ b/features/securebackup/impl/src/main/res/values-ro/translations.xml @@ -8,11 +8,11 @@ "Încărcați cheile de pe acest dispozitiv" "Permiteți stocarea cheilor" "Schimbați cheia de recuperare" - "Mesajele dumneavoastră sunt copiate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare." + "Mesajele dumneavoastră sunt copiate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare." "Introduceți cheia de recuperare" "Backup-ul pentru chat nu este sincronizat în prezent." "Obțineți cheia de recuperare" - "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 pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare." + "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 pierdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare." "Deschideți %1$s pe un dispozitiv desktop" "Conectați-vă din nou la contul dumneavoastră" "Când vi se cere să vă verificați dispozitivul, selectați%1$s" @@ -20,6 +20,28 @@ "Urmați instrucțiunile pentru a crea o nouă cheie de recuperare" "Salvați noua cheie de recuperare într-un manager de parole sau o notă criptată" "Resetați criptarea contului dumneavoastră folosind un alt dispozitiv" + "Cheia de recuperare introdusă nu se potrivește" + "Finalizați configurarea" + "Introduceți din nou cheia de recuperare." + "Confirmați cheia de recuperare" + + "Minim %1$d caracter. Nu folosiți parola contului dvs." + "Minim %1$d caractere. Nu folosiți parola contului dvs." + "Minim %1$d caractere. Nu folosiți parola contului dvs." + + "Rezistenţă" + "Rezistența parolei: %1$s" + "Moderată" + "Puternică" + "Foarte puternică" + "Foarte slabă" + "Slabă" + "Alegeți o cheie de recuperare pe care o puteți memora." + "Introduceți o cheie de recuperare" + "Se încarcă cerințele cheii de recuperare" + "Pentru a schimba cheia de recuperare, accesați Setări → Criptare → Backup" + "Puteți folosi cheia de recuperare pentru a confirma dispozitive noi sau pentru a restaura chat-urile criptate dacă pierdeți accesul la toate dispozitivele." + "Backup-ul dumneavoastră este acum complet configurat" "Continuați resetarea" "Detaliile contului, contactele, preferințele și lista de chat vor fi păstrate" "Veți pierde mesajele anterioare care au fost stocate doar pe server" diff --git a/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml b/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml index 9b791eb938..daf8a73964 100644 --- a/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/securebackup/impl/src/main/res/values-zh-rTW/translations.xml @@ -20,6 +20,26 @@ "按照說明建立新復原金鑰" "將您的新復原金鑰儲存在密碼管理程式或加密筆記中" "使用其他裝置重設您帳號的加密" + "您輸入的復原金鑰不符合" + "完成設定" + "請再次輸入您的復原金鑰。" + "確認您的復原金鑰" + + "最少 %1$d 個字元。請勿使用您的帳號密碼。" + + "強度" + "密碼短語強度:%1$s" + "中等" + "強" + "非常強" + "非常弱" + "弱" + "請輸入您可以記住的復原金鑰。" + "輸入復原金鑰" + "正在載入復原金鑰需求" + "若要變更復原金鑰,請前往「設定」→「加密」→「備份」" + "若您無法存取您所有的裝置,可以使用復原金鑰來確認新裝置或還原加密聊天紀錄。" + "您的備份已設定完成" "繼續重設" "您的帳號詳細資訊、聯絡人、偏好設定與聊天清單都會保留" "您將會遺失僅儲存在伺服器上的任何訊息歷史紀錄" diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt index 7208ee6115..514c38a3fd 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt @@ -140,7 +140,7 @@ private fun CheckableSpaceListItem( enabled: Boolean = true, ) { ListItem( - headlineContent = { + content = { Text(text = headlineText) }, supportingContent = supportingText?.let { diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt index 45fce0ca01..43feb99611 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt @@ -235,7 +235,7 @@ private fun RoomAccessSection( modifier = modifier, ) { ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) }, trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), @@ -243,7 +243,7 @@ private fun RoomAccessSection( ) if (state.showSpaceMemberOption) { ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, supportingContent = { Text(text = state.spaceMemberDescription()) }, @@ -255,7 +255,7 @@ private fun RoomAccessSection( } if (state.showAskToJoinOption) { ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) }, trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin), onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) }, @@ -265,7 +265,7 @@ private fun RoomAccessSection( } if (state.showAskToJoinWithSpaceMemberOption) { ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, supportingContent = { Text(text = state.askToJoinWithSpaceMembersDescription()) }, trailingContent = ListItemContent.RadioButton(selected = edited is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember), onClick = ::onAskToJoinWithSpaceMembersClick, @@ -274,7 +274,7 @@ private fun RoomAccessSection( ) } ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_description)) }, trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.InviteOnly), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), @@ -330,7 +330,7 @@ private fun RoomAddressSection( modifier = modifier, ) { ListItem( - headlineContent = { + content = { Text(text = roomAddress ?: stringResource(R.string.screen_security_and_privacy_add_room_address_action)) }, trailingContent = if (roomAddress.isNullOrEmpty()) ListItemContent.Icon(IconSource.Vector(CompoundIcons.Plus())) else null, @@ -340,7 +340,7 @@ private fun RoomAddressSection( ) ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_directory_visibility_toggle_description, homeserverName)) }, @@ -387,7 +387,7 @@ private fun EncryptionSection( modifier = modifier, ) { ListItem( - headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_toggle_title)) }, + content = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_toggle_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_encryption_section_footer)) }, trailingContent = ListItemContent.Switch( checked = isRoomEncrypted, @@ -459,7 +459,7 @@ private fun HistoryVisibilityItem( SecurityAndPrivacyHistoryVisibility.WorldReadable -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title) } ListItem( - headlineContent = { Text(text = headlineText) }, + content = { Text(text = headlineText) }, trailingContent = ListItemContent.RadioButton(selected = isSelected, enabled = isEnabled), onClick = { onSelectOption(option) }, enabled = isEnabled, diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt index d7985e8b06..f635cc0cce 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/OnSharedData.kt @@ -11,5 +11,8 @@ package io.element.android.features.share.api * Post-processing to be done once a [ShareIntentData] has been consumed. */ fun interface OnSharedData { + /** + * @param data the shared content that has just been handled, so any temporary copy of it can be released. + */ operator fun invoke(data: ShareIntentData) } diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt index d689c57398..d85ae9792a 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareIntentHandler.kt @@ -9,10 +9,14 @@ package io.element.android.features.share.api import android.content.Intent +/** + * Recognises the intents another app uses to share content into this one, and resolves what was shared. + */ interface ShareIntentHandler { /** * This methods aims to handle incoming share intents and parse its data. * + * @param intent the intent the app was started with. * @return the [ShareIntentData] if it could be resolved, or null. */ fun handleIncomingShareIntent( diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt index f9036f0452..add57bd4c4 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceStateProvider.kt @@ -87,6 +87,7 @@ internal fun aSelectRoomInfoList(): ImmutableList = listOf( canonicalAlias = null, avatarUrl = null, heroes = persistentListOf(), + isDm = false, isTombstoned = false, ), SelectRoomInfo( @@ -95,6 +96,7 @@ internal fun aSelectRoomInfoList(): ImmutableList = listOf( canonicalAlias = null, avatarUrl = null, heroes = persistentListOf(), + isDm = false, isTombstoned = false, ), SelectRoomInfo( @@ -103,6 +105,7 @@ internal fun aSelectRoomInfoList(): ImmutableList = listOf( canonicalAlias = null, avatarUrl = null, heroes = persistentListOf(), + isDm = false, isTombstoned = false, ), ).toImmutableList() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt index eb485f11b5..2f75072d0a 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/addroom/AddRoomToSpaceView.kt @@ -12,10 +12,15 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -112,7 +117,13 @@ fun AddRoomToSpaceView( .consumeWindowInsets(paddingValues) ) { SearchBar( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding( + WindowInsets.safeDrawing + .only(WindowInsetsSides.Horizontal) + .asPaddingValues() + ), placeHolderTitle = stringResource(CommonStrings.action_search), queryState = state.searchQuery, active = state.isSearchActive, @@ -147,23 +158,35 @@ fun AddRoomToSpaceView( } if (!state.isSearchActive) { - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.screen_space_add_rooms_room_access_description), - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmRegular, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - if (state.selectedRooms.isNotEmpty()) { - SelectedRoomsRow( - selectedRooms = state.selectedRooms, - onRemoveRoom = ::onRoomToggled, - modifier = Modifier.padding(vertical = 16.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + WindowInsets.safeDrawing + .only(WindowInsetsSides.Horizontal) + .asPaddingValues() + ) + ) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.screen_space_add_rooms_room_access_description), + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) + if (state.selectedRooms.isNotEmpty()) { + SelectedRoomsRow( + selectedRooms = state.selectedRooms, + onRemoveRoom = ::onRoomToggled, + modifier = Modifier.padding(vertical = 16.dp) + ) + } } if (state.suggestions.isNotEmpty()) { - LazyColumn { + LazyColumn( + contentPadding = lazyColumnContentPadding, + ) { item { ListSectionHeader( title = stringResource(id = CommonStrings.common_suggestions), diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 5ce6575493..de30ddb6c1 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList @@ -117,12 +118,14 @@ class SpacePresenter( val filteredChildren by remember { derivedStateOf { - val notRemoved = children.filterNot { it.roomId in removedRoomIds } + val visibleChildren = children + .filterNot { it.roomId in removedRoomIds } + .filterNot { it.cannotBeRejoined() } if (isManageMode) { // In manage mode, only show rooms (not spaces) - notRemoved.filter { !it.isSpace }.toImmutableList() + visibleChildren.filter { !it.isSpace }.toImmutableList() } else { - notRemoved.toImmutableList() + visibleChildren.toImmutableList() } } } @@ -254,3 +257,7 @@ class SpacePresenter( } } } + +private fun SpaceRoom.cannotBeRejoined(): Boolean { + return state == CurrentUserMembership.LEFT && joinRule == JoinRule.Invite +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt index ace3a1f670..6237e443b0 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsView.kt @@ -169,7 +169,7 @@ private fun SecurityAndPrivacyItem( modifier: Modifier = Modifier, ) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_space_settings_security_and_privacy)) }, + content = { Text(stringResource(R.string.screen_space_settings_security_and_privacy)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onClick, modifier = modifier, @@ -183,7 +183,7 @@ private fun MembersItem( modifier: Modifier = Modifier, ) { ListItem( - headlineContent = { Text(stringResource(CommonStrings.common_people)) }, + content = { Text(stringResource(CommonStrings.common_people)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())), trailingContent = ListItemContent.Text(memberCount.toString()), onClick = onClick, @@ -197,7 +197,7 @@ private fun RolesAndPermissionsItem( modifier: Modifier = Modifier, ) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_space_settings_roles_and_permissions)) }, + content = { Text(stringResource(R.string.screen_space_settings_roles_and_permissions)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), onClick = onClick, modifier = modifier, @@ -210,7 +210,7 @@ private fun LeaveSpaceItem( modifier: Modifier = Modifier, ) { ListItem( - headlineContent = { + content = { Text(stringResource(CommonStrings.action_leave_space)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())), diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index ba4e10a447..2de52a600b 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -30,11 +30,13 @@ import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.matrix.test.A_ROOM_ID_4 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom @@ -537,6 +539,78 @@ class SpacePresenterTest { } } + @Test + fun `present - left children which can only be joined by invitation are filtered out`() = runTest { + val leftInviteOnlyRoom = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + state = CurrentUserMembership.LEFT, + joinRule = JoinRule.Invite, + ) + val leftPublicRoom = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Room, + state = CurrentUserMembership.LEFT, + joinRule = JoinRule.Public, + ) + val leftKnockRoom = aSpaceRoom( + roomId = A_ROOM_ID_3, + roomType = RoomType.Room, + state = CurrentUserMembership.LEFT, + joinRule = JoinRule.Knock, + ) + val leftRoomWithUnknownJoinRule = aSpaceRoom( + roomId = A_ROOM_ID_4, + roomType = RoomType.Room, + state = CurrentUserMembership.LEFT, + joinRule = null, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(leftInviteOnlyRoom, leftPublicRoom, leftKnockRoom, leftRoomWithUnknownJoinRule), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList) + presenter.test { + awaitItem() + advanceUntilIdle() + val stateWithChildren = expectMostRecentItem() + assertThat(stateWithChildren.children).containsExactly(leftPublicRoom, leftKnockRoom, leftRoomWithUnknownJoinRule) + } + } + + @Test + fun `present - children which can only be joined by invitation are kept when not left`() = runTest { + val joinedRoom = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + state = CurrentUserMembership.JOINED, + joinRule = JoinRule.Invite, + ) + val invitedRoom = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Room, + state = CurrentUserMembership.INVITED, + joinRule = JoinRule.Invite, + ) + val roomWithUnknownMembership = aSpaceRoom( + roomId = A_ROOM_ID_3, + roomType = RoomType.Room, + state = null, + joinRule = JoinRule.Invite, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(joinedRoom, invitedRoom, roomWithUnknownMembership), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList) + presenter.test { + awaitItem() + advanceUntilIdle() + val stateWithChildren = expectMostRecentItem() + assertThat(stateWithChildren.children).containsExactly(joinedRoom, invitedRoom, roomWithUnknownMembership) + } + } + @Test fun `present - removed rooms persist after flow update on partial failure`() = runTest { // On partial failure, successfully removed rooms should stay filtered even after flow updates diff --git a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt index 6aa2ad5d98..2e5a92f18e 100644 --- a/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt +++ b/features/startchat/api/src/main/kotlin/io/element/android/features/startchat/api/StartDMAction.kt @@ -13,6 +13,11 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser +/** + * Opens a direct message with a user, reusing the existing room when there is one. + * + * Progress is reported into the state passed in, so the caller only has to render it. + */ interface StartDMAction { /** * Try to find an existing DM with the given user, or create one if none exists. diff --git a/features/startchat/impl/src/main/res/values-de/translations.xml b/features/startchat/impl/src/main/res/values-de/translations.xml index 7608517314..61137aea5b 100644 --- a/features/startchat/impl/src/main/res/values-de/translations.xml +++ b/features/startchat/impl/src/main/res/values-de/translations.xml @@ -5,7 +5,7 @@ "Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten" "Chat per Adresse beitreten" "Keine gültige Adresse" - "Eintreten…" + "Chatraum Adresse eingeben …" "Passender Chat gefunden" "Chat nicht gefunden" "z. B. #room -name:matrix.org" diff --git a/features/startchat/impl/src/main/res/values-ro/translations.xml b/features/startchat/impl/src/main/res/values-ro/translations.xml index 315191a182..d003475a9f 100644 --- a/features/startchat/impl/src/main/res/values-ro/translations.xml +++ b/features/startchat/impl/src/main/res/values-ro/translations.xml @@ -5,7 +5,7 @@ "A apărut o eroare la încercarea începerii conversației" "Gasiți o cameră după adresă" "Adresa nu e este validă" - "Introduceți…" + "Introduceți adresa…" "S-a găsit o cameră" "Nu a putut fi găsită nici o cameră" "de exemplu #nume-camera:matrix.org" diff --git a/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml b/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml index c6dcd50008..d104b0ce1b 100644 --- a/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/startchat/impl/src/main/res/values-zh-rTW/translations.xml @@ -5,7 +5,7 @@ "嘗試開始聊天時發生錯誤" "按地址加入聊天室" "不是有效的位址" - "輸入……" + "輸入地址⋯⋯" "找到相符的聊天室" "找不到聊天室" "例如 #room-name:matrix.org" diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt index 4bf68ae324..80a21505f5 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfilePresenterFactory.kt @@ -11,6 +11,12 @@ package io.element.android.features.userprofile.api import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.core.UserId +/** + * Creates the user profile presenter, so that other features can show a profile without depending on this one's implementation. + */ fun interface UserProfilePresenterFactory { + /** + * @param userId the user whose profile is shown. + */ fun create(userId: UserId): Presenter } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index a09a091834..c5a661dbac 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -137,7 +137,7 @@ private fun VerifyUserSection( ) { if (state.verificationState == UserProfileVerificationState.UNVERIFIED) { ListItem( - headlineContent = { Text(stringResource(CommonStrings.common_verify_user)) }, + content = { Text(stringResource(CommonStrings.common_verify_user)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onVerifyClick, ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt index 62efbf1f0f..30f8c28607 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt @@ -80,14 +80,14 @@ private fun PreferenceBlockUser( } if (isBlocked.orFalse()) { ListItem( - headlineContent = { Text(stringResource(R.string.screen_dm_details_unblock_user)) }, + content = { Text(stringResource(R.string.screen_dm_details_unblock_user)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), onClick = { if (!isLoading) eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true)) }, trailingContent = if (isLoading) ListItemContent.Custom(loadingCurrentValue) else null, ) } else { ListItem( - headlineContent = { Text(stringResource(R.string.screen_dm_details_block_user)) }, + content = { Text(stringResource(R.string.screen_dm_details_block_user)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())), style = ListItemStyle.Destructive, onClick = { if (!isLoading) eventSink(UserProfileEvents.BlockUser(needsConfirmation = true)) }, diff --git a/features/verifysession/impl/src/main/res/values-pl/translations.xml b/features/verifysession/impl/src/main/res/values-pl/translations.xml index 39ce9f6fa3..01a9325ceb 100644 --- a/features/verifysession/impl/src/main/res/values-pl/translations.xml +++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml @@ -11,16 +11,16 @@ "Użyj innego urządzenia" "Oczekiwanie na inne urządzenie…" "Coś tu nie gra. Albo upłynął limit czasu, albo żądanie zostało odrzucone." - "Upewnij się, że emoji poniżej pasują do tych pokazanych na innej sesji." + "Upewnij się, że zestaw emoji poniżej pasuje do tych na drugiej sesji." "Porównaj emoji" "Upewnij się, że emoji poniżej odpowiadają tym na urządzeniu drugiego użytkownika." - "Upewnij się, że liczby poniżej pasują do tych wyświetlanych na innej sesji." + "Upewnij się, że liczby poniżej pasują do tych wyświetlanych na drugiej sesji." "Porównaj liczby" "Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu." "Teraz możesz zaufać tożsamości tego użytkownika podczas wysyłania lub odbierania wiadomości." "Urządzenie zweryfikowane" "Wprowadź klucz przywracania" - "Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji." + "Upłynął limit czasu żądania, żądanie zostało odrzucone lub wystąpił błąd weryfikacji." "Udowodnij, że to ty, aby uzyskać dostęp do historii zaszyfrowanych wiadomości." "Otwórz istniejącą sesję" "Ponów weryfikację" @@ -29,9 +29,9 @@ "Porównaj unikalny zestaw emoji." "Porównaj unikalny zestaw emoji i upewnij się, że są w tej samej kolejności." "Zalogowano" - "Albo upłynął limit czasu żądania, albo żądanie zostało odrzucone, albo wystąpił błąd weryfikacji." + "Upłynął limit czasu żądania, żądanie zostało odrzucone lub wystąpił błąd weryfikacji." "Weryfikacja nie powiodła się" - "Kontynuuj tylko, jeśli to Ty zainicjowałeś tę weryfikację." + "Kontynuuj tylko jeśli to Ty zainicjowałeś proces weryfikacji." "Zweryfikuj drugie urządzenie, aby zabezpieczyć historię wiadomości." "Teraz możesz bezpiecznie czytać i wysyłać wiadomości na swoim drugim urządzeniu." "Urządzenie zweryfikowane" diff --git a/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt index 29a7c9f9e8..5abc27546d 100644 --- a/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt +++ b/features/viewfolder/api/src/main/kotlin/io/element/android/features/viewfolder/api/TextFileViewer.kt @@ -12,7 +12,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import kotlinx.collections.immutable.ImmutableList +/** + * Renders the contents of a text file, used by the log viewer in the developer options. + */ fun interface TextFileViewer { + /** + * Draws the file, one row per line. + * + * @param lines the already-read contents of the file. + * @param modifier layout modifier for the container. + */ @Composable fun Render( lines: ImmutableList, diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt index 2a6b4031ef..86cd5302c8 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt @@ -100,7 +100,7 @@ private fun ItemRow( Item.Parent -> { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.SubdirectoryArrowLeft)), - headlineContent = { + content = { Text( text = "..", modifier = Modifier.padding(16.dp), @@ -113,7 +113,7 @@ private fun ItemRow( is Item.Folder -> { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(Icons.Outlined.Folder)), - headlineContent = { + content = { Text( text = item.name, modifier = Modifier.padding(16.dp), @@ -126,7 +126,7 @@ private fun ItemRow( is Item.File -> { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Document())), - headlineContent = { + content = { Text( text = item.name, modifier = Modifier.padding(16.dp), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 48bf6f008a..17eb8ff751 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,14 +7,14 @@ android_gradle_plugin = "9.3.1" # When updating this, please also update the version in the file ./idea/kotlinc.xml kotlin = "2.4.10" kotlinpoet = "2.3.0" -ksp = "2.3.10" +ksp = "2.3.11" firebaseAppDistribution = "5.3.0" # AndroidX core = "1.18.0" datastore = "1.2.1" -constraintlayout = "2.2.1" -constraintlayout_compose = "1.1.1" +constraintlayout = "2.2.2" +constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" media3 = "1.10.1" @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.70.0" +roborazzi = "1.71.0" # Jetbrain datetime = "0.8.0" @@ -51,10 +51,10 @@ telephoto = "0.19.0" haze = "1.7.2" # Dependency analysis -dependencyAnalysis = "3.17.0" +dependencyAnalysis = "3.18.0" # DI -metro = "1.3.2" +metro = "1.4.1" # Auto service autoservice = "1.1.1" @@ -84,7 +84,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:34.16.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.17.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } @@ -126,7 +126,7 @@ androidx_preference = "androidx.preference:preference:1.2.1" androidx_webkit = "androidx.webkit:webkit:1.16.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } -androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha22' } +androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' } androidx_compose_material3_windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } androidx_compose_material3_adaptive = "androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06" androidx_compose_ui = { module = "androidx.compose.ui:ui" } @@ -185,7 +185,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.07.28" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.08.13" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -199,7 +199,7 @@ serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-jso kotlinx_collections_immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.1" showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } -jsoup = "org.jsoup:jsoup:1.22.2" +jsoup = "org.jsoup:jsoup:1.23.1" appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } molecule-runtime = "app.cash.molecule:molecule-runtime:2.2.0" timber = "com.jakewharton.timber:timber:5.0.1" @@ -215,12 +215,12 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:13.4.0" +maplibre = "org.maplibre.gl:android-sdk:13.4.1" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_compose = "org.maplibre.compose:maplibre-compose:0.13.1" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" -zxing_cpp = "io.github.zxing-cpp:android:3.1.0" +zxing_cpp = "io.github.zxing-cpp:android:3.1.1" google_zxing = "com.google.zxing:core:3.5.4" google_guava = "com.google.guava:guava:33.6.0-android" @@ -229,8 +229,8 @@ haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics -posthog = "com.posthog:posthog-android:3.51.1" -sentry = "io.sentry:sentry-android:8.50.0" +posthog = "com.posthog:posthog-android:3.56.4" +sentry = "io.sentry:sentry-android:8.52.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.37.0" @@ -242,7 +242,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.22.0" +element_call_embedded = "io.element.android:element-call-embedded:0.23.0" # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } @@ -270,13 +270,13 @@ metro = { id = "dev.zacsweers.metro", version.ref = "metro" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } ktlint = "org.jlleitschuh.gradle.ktlint:14.2.0" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" -dependencycheck = "org.owasp.dependencycheck:12.2.2" +dependencycheck = "org.owasp.dependencycheck:13.0.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } paparazzi = "app.cash.paparazzi:2.0.0-alpha05" roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } -sonarqube = "org.sonarqube:7.3.1.8318" +sonarqube = "org.sonarqube:7.4.0.8496" licensee = "app.cash.licensee:1.14.1" compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56b4..eddabd2eef 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dbe66e1d66..84c5a9c661 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=84fbba45c7f4c64abc77460e1c00f541e9f960e3c7ed2538f1ede19eacd873ae +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt index 441f819546..090936910e 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -16,6 +16,7 @@ import androidx.core.text.toSpannable import androidx.core.text.util.LinkifyCompat import io.element.android.libraries.core.extensions.runCatchingExceptions import timber.log.Timber +import java.net.URI /** * Helper class to linkify text while preserving existing URL spans. @@ -23,6 +24,8 @@ import timber.log.Timber * It also checks the linkified results to make sure URLs spans are not including trailing punctuation. */ object LinkifyHelper { + private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(?(start, newEnd)) { + spannable.removeSpan(span) + } + spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + // Restore old spans, remove new ones if there is a conflict for ((urlSpan, location) in oldURLSpans) { val (start, end) = location @@ -82,6 +108,15 @@ object LinkifyHelper { return spannable } + private fun String.hasBracketedHost(): Boolean { + val host = runCatchingExceptions { URI(this) }.getOrNull()?.host ?: return false + return host.startsWith('[') && host.endsWith(']') + } + + private fun Spannable.isEmailInsideFediverseHandle(urlSpan: URLSpan, start: Int): Boolean { + return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@' + } + private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int { var end = end diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt index dde63e79a6..5e78c3c1ac 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt @@ -13,3 +13,5 @@ import java.nio.charset.Charset fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name()) fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name()) + +fun String.takeIfNotBlank(): String? = ifBlank { null } diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt index bb7a272908..130d640d4a 100644 --- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt @@ -36,6 +36,60 @@ class LinkifierHelperTest : RobolectricTest() { assertThat(urlSpans.first().url).isEqualTo("https://matrix.org") } + @Test + fun `linkification finds ipv6 URL`() { + val text = "A url http://[2001:db8::1]:8008/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path") + } + + @Test + @Config(sdk = [30]) + fun `linkification of an ipv6 URL does not leave a phone number span`() { + shadowOf(newInstanceOf(TelephonyManager::class.java)).setSimCountryIso("DE") + val text = "A url http://[2001:db8::1]:8008/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path") + } + + @Test + fun `linkification of an ipv6 URL trims trailing punctuation`() { + val text = "A url http://[::1]/x." + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x") + } + + @Test + fun `linkification finds an ipv6 URL of any scheme`() { + val text = "A url ssh://[2001:db8::1]/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("ssh://[2001:db8::1]/path") + } + + @Test + fun `linkification ignores a bracketed authority which is not an address`() { + val text = "A url http://[not-an-address]/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + + @Test + fun `linkification ignores bracketed text which is not a URL`() { + val text = "An array values[0]:1 and a bare [::1]" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + @Test fun `linkification finds partial URL`() { val text = "A partial url matrix.org/test" @@ -87,6 +141,45 @@ class LinkifierHelperTest : RobolectricTest() { assertThat(urlSpans.first().url).isEqualTo("tel:1234") } + @Test + fun `linkification ignores fediverse handle`() { + val text = "Follow me at @name@server.tld" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + + @Test + fun `linkification ignores fediverse handle at the start of the text`() { + val text = "@name@server.tld posted this" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + + @Test + fun `linkification finds email next to a fediverse handle`() { + val text = "@name@server.tld and john@doe.com" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com") + } + + @Test + fun `linkification keeps an existing mailto span preceded by an at sign`() { + val text = buildSpannedString { + append("Mail me @") + inSpans(URLSpan("mailto:john@doe.com")) { + append("john@doe.com") + } + } + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com") + } + @Test fun `linkification handles trailing dot`() { val text = "A url https://matrix.org." diff --git a/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt index aeccaa9b8a..de2e5844d5 100644 --- a/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt +++ b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt @@ -15,6 +15,9 @@ enum class AudioFocusRequester { MediaViewer, } +/** + * Claims and releases system audio focus, so that the app's own players do not talk over each other or over other apps. + */ interface AudioFocus { /** * Request audio focus for the given requester. diff --git a/libraries/cachestore/api/src/main/kotlin/io/element/android/libraries/cachestore/api/CacheStore.kt b/libraries/cachestore/api/src/main/kotlin/io/element/android/libraries/cachestore/api/CacheStore.kt index 5df446f688..bbfb7d3405 100644 --- a/libraries/cachestore/api/src/main/kotlin/io/element/android/libraries/cachestore/api/CacheStore.kt +++ b/libraries/cachestore/api/src/main/kotlin/io/element/android/libraries/cachestore/api/CacheStore.kt @@ -7,9 +7,33 @@ package io.element.android.libraries.cachestore.api +/** + * A generic on-disk key/value cache, where each entry records when it was written so callers can decide for themselves when it is stale. + */ interface CacheStore { + /** + * Stores an entry, replacing any previous one under the same key. + * + * @param key the entry to write. + * @param data the value along with the time it was obtained. + */ suspend fun storeData(key: String, data: CacheData) + + /** + * Reads an entry without applying any expiry of its own; judging staleness is up to the caller. + * + * @param key the entry to read. + * @return the value and its timestamp, or `null` when nothing is stored under that key. + */ suspend fun getData(key: String): CacheData? + + /** + * Removes one entry. + * + * @param key the entry to delete. + */ suspend fun deleteData(key: String) + + /** Empties the whole cache. */ suspend fun deleteAll() } diff --git a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt index fc882bc54b..1e2866a20f 100644 --- a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt +++ b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/EncryptionDecryptionService.kt @@ -15,8 +15,35 @@ import javax.crypto.SecretKey * Simple service to provide encryption and decryption operations. */ interface EncryptionDecryptionService { + /** + * Creates a cipher ready to encrypt, for the callers that need the cipher itself, such as to bind it to a biometric prompt. + * + * @param key the key to encrypt with. + */ fun createEncryptionCipher(key: SecretKey): Cipher + + /** + * Creates a cipher ready to decrypt, for the callers that need the cipher itself. + * + * @param key the key to decrypt with. + * @param initializationVector the vector produced when the data was encrypted. + */ fun createDecryptionCipher(key: SecretKey, initializationVector: ByteArray): Cipher + + /** + * Encrypts data in one call. + * + * @param key the key to encrypt with. + * @param input the plaintext bytes. + * @return the ciphertext along with the initialization vector needed to decrypt it. + */ fun encrypt(key: SecretKey, input: ByteArray): EncryptionResult + + /** + * Decrypts data in one call. + * + * @param key the key the data was encrypted with. + * @param encryptionResult the ciphertext and its initialization vector, as returned by [encrypt]. + */ fun decrypt(key: SecretKey, encryptionResult: EncryptionResult): ByteArray } diff --git a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt index b210664d9f..c68f3170b9 100644 --- a/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt +++ b/libraries/cryptography/api/src/main/kotlin/io/element/android/libraries/cryptography/api/SecretKeyRepository.kt @@ -16,10 +16,17 @@ import javax.crypto.SecretKey * Implementation should be able to store the generated key securely. */ interface SecretKeyRepository { + /** + * Whether a key exists for the given alias, re-emitted when it is created or deleted. + * + * @param alias the alias to watch. + */ fun hasKey(alias: String): Flow /** * Get or create a secret key for a given alias. + * Note that [requiresUserAuthentication] only applies when the key is created: an existing key keeps the protection it was created with. + * * @param alias the alias to use * @param requiresUserAuthentication true if the key should be protected by user authentication */ diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt index 1f5c88f008..84cbf5efdd 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DateFormatter.kt @@ -8,7 +8,17 @@ package io.element.android.libraries.dateformatter.api +/** + * Formats timestamps for display, in the user's locale and time zone. + */ interface DateFormatter { + /** + * Formats a timestamp according to [mode]. + * + * @param timestamp milliseconds since the epoch; a `null` value yields an empty string, so callers need no null check of their own. + * @param mode how much of the date and time to include; see [DateFormatterMode] for examples of each. + * @param useRelative true to prefer wording relative to today, such as "Today" or "This month", where the mode allows it. + */ fun format( timestamp: Long?, mode: DateFormatterMode = DateFormatterMode.Full, diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt index ac297d10e9..712355c423 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt @@ -21,6 +21,11 @@ import kotlin.time.Duration * - 30 seconds → "30 seconds" */ interface DurationFormatter { + /** + * Formats the duration using its largest appropriate unit, rounding rather than listing several units. + * + * @param duration the duration to describe. + */ fun format(duration: Duration): String } diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt index 2b46b15e46..938939c6f0 100644 --- a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeepLinkCreator.kt @@ -13,6 +13,17 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId +/** + * Builds the app's internal deep links, used to point a notification or a shortcut at a precise place in the app. + */ fun interface DeepLinkCreator { + /** + * Builds a link to the most precise target the arguments allow, ignoring the deeper ones once an outer one is `null`. + * + * @param sessionId the session to open. + * @param roomId the room to open, or `null` to stop at the room list. + * @param threadId the thread to open within that room, or `null` for the main timeline. + * @param eventId the event to focus on, or `null` to open at the latest message. + */ fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String } diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt index f0f8640a2d..7a711d8a3a 100644 --- a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/DeeplinkParser.kt @@ -10,6 +10,13 @@ package io.element.android.libraries.deeplink.api import android.content.Intent +/** + * Extracts the app's own deep link data from an incoming intent; see [DeepLinkCreator] for the other direction. + */ fun interface DeeplinkParser { + /** + * @param intent the intent the app was started or resumed with. + * @return the session, room and event to open, or `null` when the intent is not one of the app's deep links. + */ fun getFromIntent(intent: Intent): DeeplinkData? } diff --git a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt index 64287f918c..2c4406a482 100644 --- a/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt +++ b/libraries/deeplink/api/src/main/kotlin/io/element/android/libraries/deeplink/api/usecase/InviteFriendsUseCase.kt @@ -10,6 +10,12 @@ package io.element.android.libraries.deeplink.api.usecase import android.app.Activity +/** + * Opens the system share sheet with an invitation to install the app. + */ fun interface InviteFriendsUseCase { + /** + * @param activity the activity the share sheet is shown from. + */ fun execute(activity: Activity) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionIndicator.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionIndicator.kt new file mode 100644 index 0000000000..f1a58c243f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionIndicator.kt @@ -0,0 +1,114 @@ +/* + * 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.libraries.designsystem.components.async + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +private val DefaultShowDelay = 300.milliseconds +private val DefaultMinDisplayDuration = 500.milliseconds +private val DefaultFailureDuration = 500.milliseconds + +/** + * An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing. + * + * While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation + * runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This + * prevents the indicator from flashing for operations that complete almost instantly. + * + * When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are + * never debounced) and auto-dismisses after [failureDuration]. + * + * @param asyncAction the action whose progress drives the indicator. + * @param loading the composable to display while loading. + * @param failure the composable to display on failure. + * @param modifier the modifier to apply to the underlying [AsyncIndicatorHost]. + * @param state the [AsyncIndicatorState] backing the host. + * @param showDelay how long the loading state must persist before the indicator is shown. + * @param minDisplayDuration the minimum duration the loading indicator stays visible once shown. + * @param failureDuration how long the failure indicator stays visible before auto-dismissing. + */ +@Composable +fun AsyncActionIndicator( + asyncAction: AsyncAction<*>, + loading: @Composable () -> Unit, + failure: @Composable (Throwable) -> Unit, + modifier: Modifier = Modifier, + state: AsyncIndicatorState = rememberAsyncIndicatorState(), + showDelay: Duration = DefaultShowDelay, + minDisplayDuration: Duration = DefaultMinDisplayDuration, + failureDuration: Duration = DefaultFailureDuration, +) { + AsyncIndicatorHost(modifier = modifier, state = state) + AsyncActionIndicatorEffect( + asyncAction = asyncAction, + state = state, + showDelay = showDelay, + minDisplayDuration = minDisplayDuration, + failureDuration = failureDuration, + loading = loading, + failure = failure, + ) +} + +@Composable +internal fun AsyncActionIndicatorEffect( + asyncAction: AsyncAction<*>, + state: AsyncIndicatorState, + showDelay: Duration, + minDisplayDuration: Duration, + failureDuration: Duration, + loading: @Composable () -> Unit, + failure: @Composable (Throwable) -> Unit, + timeSource: TimeSource = TimeSource.Monotonic, +) { + // Referenced inside the restarting LaunchedEffect below, so keep them up to date. + val currentLoading by rememberUpdatedState(loading) + val currentFailure by rememberUpdatedState(failure) + // Mark set when the loader actually becomes visible, null while it is hidden. + var loaderShownAt by remember { mutableStateOf(null) } + LaunchedEffect(asyncAction) { + when (asyncAction) { + is AsyncAction.Loading -> { + // If the operation completes before this delay elapses, the effect is cancelled + // and the loader is never shown. + delay(showDelay) + state.enqueue(composable = currentLoading) + loaderShownAt = timeSource.markNow() + } + is AsyncAction.Failure -> { + // Always surface failures immediately, even if the loader was never shown. + val error = asyncAction.error + state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) }) + loaderShownAt = null + } + is AsyncAction.Success -> { + // Hold the loader for its minimum duration before clearing it. + loaderShownAt?.let { shownAt -> + val remaining = minDisplayDuration - shownAt.elapsedNow() + if (remaining > Duration.ZERO) delay(remaining) + } + state.clear() + loaderShownAt = null + } + else -> Unit + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt index c6a85e9b85..c8f8b02b0f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/AvatarListItem.kt @@ -45,7 +45,7 @@ fun AvatarListItem( ) { ListItem( modifier = modifier, - headlineContent = { Text(headline) }, + content = { Text(headline) }, supportingContent = supportingText?.let { @Composable { Text(it) } }, leadingContent = ListItemContent.Custom { _ -> Avatar( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt index 4775b91720..701e1b7eaf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/CheckboxListItem.kt @@ -28,7 +28,7 @@ fun CheckboxListItem( ) { ListItem( modifier = modifier, - headlineContent = { Text(headline) }, + content = { Text(headline) }, supportingContent = supportingText?.let { @Composable { Text(it) } }, leadingContent = ListItemContent.Checkbox( checked = checked, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt index f314f17008..cdd0811aec 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt @@ -68,7 +68,7 @@ fun MultipleSelectionListItem( ListItem( modifier = modifier, - headlineContent = { Text(text = headline) }, + content = { Text(text = headline) }, supportingContent = decoratedSupportedText, leadingContent = leadingContent, trailingContent = trailingContent, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt index 70fb91e2e6..dc690294eb 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt @@ -28,7 +28,7 @@ fun RadioButtonListItem( ) { ListItem( modifier = modifier, - headlineContent = { Text(headline) }, + content = { Text(headline) }, supportingContent = supportingText?.let { @Composable { Text(it) } }, leadingContent = ListItemContent.RadioButton( selected = selected, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt index e07cda5e77..d9ce794938 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt @@ -67,7 +67,7 @@ fun SingleSelectionListItem( ListItem( modifier = modifier, - headlineContent = { Text(text = headline) }, + content = { Text(text = headline) }, supportingContent = decoratedSupportedText, leadingContent = leadingContent, trailingContent = trailingContent, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt index c5016bc5bc..f99b971b26 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SwitchListItem.kt @@ -27,7 +27,7 @@ fun SwitchListItem( ) { ListItem( modifier = modifier, - headlineContent = { Text(headline) }, + content = { Text(headline) }, supportingContent = supportingText?.let { @Composable { Text(it) } }, leadingContent = leadingContent, trailingContent = ListItemContent.Switch( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index f91cf96a95..fe9507cb45 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -200,11 +200,15 @@ internal fun WaveformPlaybackViewPreview() = ElementPreview { } } -private fun ImmutableList.normalisedData(maxSamplesCount: Int): ImmutableList { +internal fun ImmutableList.normalisedData(maxSamplesCount: Int): ImmutableList { if (maxSamplesCount <= 0) { return persistentListOf() } + if (isEmpty()) { + return List(maxSamplesCount) { 0f }.toImmutableList() + } + // Filter the data to keep only the expected number of samples val result = if (this.size > maxSamplesCount) { (0.. PreferenceDropdown( iconResourceId = iconResourceId, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), - headlineContent = { + content = { Text( style = ElementTheme.typography.fontBodyLgRegular, modifier = Modifier.fillMaxWidth(), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt index efc36544b9..9762ca6c18 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt @@ -30,7 +30,7 @@ fun PreferenceRow( ) { ListItem( modifier = modifier, - headlineContent = { + content = { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt index 671eb5bf3f..5ff97ef37f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt @@ -46,7 +46,7 @@ fun PreferenceSlide( iconResourceId = iconResourceId, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), - headlineContent = { + content = { Column { Text( style = ElementTheme.typography.fontBodyLgRegular, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt index 4545dbdf3e..bf4b76ba81 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -44,7 +44,7 @@ fun PreferenceSwitch( iconResourceId = iconResourceId, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), - headlineContent = { + content = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt index c67b4c857f..ad65ac4d8c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt @@ -42,7 +42,7 @@ fun PreferenceTextField( ListItem( modifier = modifier, - headlineContent = { Text(headline) }, + content = { Text(headline) }, supportingContent = valueToDisplay?.let { @Composable { Text(it) } }, trailingContent = trailingContent, style = style, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt index bbc97301dd..c4aeb4582f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt @@ -35,20 +35,19 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup /** * A List Item component to be used in lists and menus with simple layouts, matching the Material 3 guidelines. - * @param headlineContent The main content of the list item, usually a text. * @param modifier The modifier to be applied to the list item. - * @param supportingContent The content to be displayed below the headline content. - * @param leadingContent The content to be displayed before the headline content. - * @param trailingContent The content to be displayed after the headline content. + * @param supportingContent The content to be displayed below the content. + * @param leadingContent The content to be displayed before the content. + * @param trailingContent The content to be displayed after the content. * @param style The style to use for the list item. This may change the color and text styles of the contents. [ListItemStyle.Default] is used by default. - * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. + * @param enabled Whether the list item is enabled. When disabled, will change the color of the content and the leading content to use disabled tokens. * @param alwaysClickable Whether the list item should always be clickable, even when disabled. * @param onClick The callback to be called when the list item is clicked. + * @param content The main content of the list item, usually a text. */ @Suppress("LongParameterList") @Composable fun ListItem( - headlineContent: @Composable () -> Unit, modifier: Modifier = Modifier, supportingContent: @Composable (() -> Unit)? = null, leadingContent: ListItemContent? = null, @@ -57,19 +56,19 @@ fun ListItem( enabled: Boolean = true, alwaysClickable: Boolean = false, onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, ) { val colors = ListItemDefaults.colors( containerColor = Color.Transparent, - headlineColor = style.headlineColor(), + headlineColor = style.contentColor(), leadingIconColor = style.leadingIconColor(), trailingIconColor = style.trailingIconColor(), supportingColor = style.supportingTextColor(), - disabledHeadlineColor = ListItemDefaultColors.headlineDisabled, + disabledHeadlineColor = ListItemDefaultColors.contentDisabled, disabledLeadingIconColor = ListItemDefaultColors.iconDisabled, disabledTrailingIconColor = ListItemDefaultColors.iconDisabled, ) ListItem( - headlineContent = headlineContent, modifier = modifier, supportingContent = supportingContent, leadingContent = leadingContent, @@ -78,25 +77,25 @@ fun ListItem( enabled = enabled, alwaysClickable = alwaysClickable, onClick = onClick, + content = content, ) } /** * A List Item component to be used in lists and menus with simple layouts, matching the Material 3 guidelines. - * @param headlineContent The main content of the list item, usually a text. * @param colors The colors to use for the list item. You can use [ListItemDefaults.colors] to create this. * @param modifier The modifier to be applied to the list item. - * @param supportingContent The content to be displayed below the headline content. - * @param leadingContent The content to be displayed before the headline content. - * @param trailingContent The content to be displayed after the headline content. - * @param enabled Whether the list item is enabled. When disabled, will change the color of the headline content and the leading content to use disabled tokens. + * @param supportingContent The content to be displayed below the content. + * @param leadingContent The content to be displayed before the content. + * @param trailingContent The content to be displayed after the content. + * @param enabled Whether the list item is enabled. When disabled, will change the color of the content and the leading content to use disabled tokens. * @param alwaysClickable Whether the list item should always be clickable, even when disabled. * @param onClick The callback to be called when the list item is clicked. + * @param content The main content of the list item, usually a text. */ @Suppress("LongParameterList") @Composable fun ListItem( - headlineContent: @Composable () -> Unit, colors: ListItemColors, modifier: Modifier = Modifier, supportingContent: @Composable (() -> Unit)? = null, @@ -105,53 +104,53 @@ fun ListItem( enabled: Boolean = true, alwaysClickable: Boolean = false, onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, ) { // We cannot just pass the disabled colors, they must be set manually: https://issuetracker.google.com/issues/280480132 - val headlineColor = if (enabled) colors.contentColor else colors.disabledContentColor + val contentColor = if (enabled) colors.contentColor else colors.disabledContentColor val supportingColor = if (enabled) colors.supportingContentColor else colors.disabledContentColor.copy(alpha = 0.80f) val leadingContentColor = if (enabled) colors.leadingContentColor else colors.disabledLeadingContentColor val trailingContentColor = if (enabled) colors.trailingContentColor else colors.disabledTrailingContentColor - val decoratedHeadlineContent: @Composable () -> Unit = { + val decoratedContent: @Composable () -> Unit = { CompositionLocalProvider( LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular, - LocalContentColor provides headlineColor, + LocalContentColor provides contentColor, ) { - headlineContent() + content() } } - val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { content -> + val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { safeContent -> { CompositionLocalProvider( LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular, LocalContentColor provides supportingColor, ) { - content() + safeContent() } } } - val decoratedLeadingContent: (@Composable () -> Unit)? = leadingContent?.let { content -> + val decoratedLeadingContent: (@Composable () -> Unit)? = leadingContent?.let { safeContent -> { CompositionLocalProvider( LocalContentColor provides leadingContentColor, ) { - content.View(isItemEnabled = enabled) + safeContent.View(isItemEnabled = enabled) } } } - val decoratedTrailingContent: (@Composable () -> Unit)? = trailingContent?.let { content -> + val decoratedTrailingContent: (@Composable () -> Unit)? = trailingContent?.let { safeContent -> { CompositionLocalProvider( LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular, LocalContentColor provides trailingContentColor, ) { - content.View(isItemEnabled = enabled) + safeContent.View(isItemEnabled = enabled) } } } androidx.compose.material3.ListItem( - headlineContent = decoratedHeadlineContent, modifier = if (onClick != null) { Modifier .clickable(enabled = enabled || alwaysClickable, onClick = onClick) @@ -169,8 +168,8 @@ fun ListItem( leadingContent = decoratedLeadingContent, trailingContent = decoratedTrailingContent, colors = colors, - tonalElevation = 0.dp, - shadowElevation = 0.dp, + elevation = ListItemDefaults.elevation(elevation = 0.dp), + content = decoratedContent, ) } @@ -223,8 +222,8 @@ sealed interface ListItemStyle { data object Destructive : ListItemStyle @Composable - fun headlineColor() = when (this) { - Default, Primary -> ListItemDefaultColors.headline + fun contentColor() = when (this) { + Default, Primary -> ListItemDefaultColors.content Destructive -> ElementTheme.colors.textCriticalPrimary } @@ -251,8 +250,8 @@ sealed interface ListItemStyle { } object ListItemDefaultColors { - val headline: Color @Composable get() = ElementTheme.colors.textPrimary - val headlineDisabled: Color @Composable get() = ElementTheme.colors.textDisabled + val content: Color @Composable get() = ElementTheme.colors.textPrimary + val contentDisabled: Color @Composable get() = ElementTheme.colors.textDisabled val supportingText: Color @Composable get() = ElementTheme.materialColors.onSurfaceVariant val leadingIcon: Color @Composable get() = ElementTheme.colors.iconSecondary val trailingIcon: Color @Composable get() = ElementTheme.colors.iconPrimary @@ -260,11 +259,11 @@ object ListItemDefaultColors { val colors: ListItemColors @Composable get() = ListItemDefaults.colors( - headlineColor = headline, + headlineColor = content, supportingColor = supportingText, leadingIconColor = leadingIcon, trailingIconColor = trailingIcon, - disabledHeadlineColor = headlineDisabled, + disabledHeadlineColor = contentDisabled, disabledLeadingIconColor = iconDisabled, disabledTrailingIconColor = iconDisabled, ) @@ -523,7 +522,7 @@ private object PreviewItems { ) { EnabledDisabledElementThemedPreview { ListItem( - headlineContent = headline(), + content = content(), supportingContent = text(), leadingContent = leadingContent, trailingContent = trailingContent, @@ -543,7 +542,7 @@ private object PreviewItems { ) { EnabledDisabledElementThemedPreview { ListItem( - headlineContent = headline(), + content = content(), supportingContent = textSingleLine(), leadingContent = leadingContent, trailingContent = trailingContent, @@ -563,7 +562,7 @@ private object PreviewItems { ) { EnabledDisabledElementThemedPreview { ListItem( - headlineContent = headline(), + content = content(), leadingContent = leadingContent, trailingContent = trailingContent, enabled = it, @@ -574,7 +573,7 @@ private object PreviewItems { } @Composable - fun headline() = @Composable { + fun content() = @Composable { Text("List item") } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt index 4be53ddb39..b4f374cf4a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListSupportingText.kt @@ -134,7 +134,7 @@ internal fun ListSupportingTextNoPaddingPreview() { internal fun ListSupportingTextDefaultPaddingPreview() { ElementThemedPreview { Column { - ListItem(headlineContent = { Text("A title") }) + ListItem(content = { Text("A title") }) ListSupportingText( text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", contentPadding = ListSupportingTextDefaults.Padding.Default, @@ -149,7 +149,7 @@ internal fun ListSupportingTextSmallPaddingPreview() { ElementThemedPreview { Column { ListItem( - headlineContent = { Text("A title") }, + content = { Text("A title") }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())) ) ListSupportingText( @@ -165,7 +165,7 @@ internal fun ListSupportingTextSmallPaddingPreview() { internal fun ListSupportingTextLargePaddingPreview() { ElementThemedPreview { Column { - ListItem(headlineContent = { Text("A title") }, leadingContent = ListItemContent.Switch(checked = true)) + ListItem(content = { Text("A title") }, leadingContent = ListItemContent.Switch(checked = true)) ListSupportingText( text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", contentPadding = ListSupportingTextDefaults.Padding.LargeLeadingContent, @@ -179,7 +179,7 @@ internal fun ListSupportingTextLargePaddingPreview() { internal fun ListSupportingTextCustomPaddingPreview() { ElementThemedPreview { Column { - ListItem(headlineContent = { Text("A title") }) + ListItem(content = { Text("A title") }) ListSupportingText( text = "Supporting line text lorem ipsum dolor sit amet, consectetur. Read more", contentPadding = ListSupportingTextDefaults.Padding.Custom(24.dp), diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncActionIndicatorTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncActionIndicatorTest.kt new file mode 100644 index 0000000000..426aa71ce7 --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncActionIndicatorTest.kt @@ -0,0 +1,123 @@ +/* + * 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.libraries.designsystem.component.async + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect +import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TestTimeSource +import kotlin.time.TimeSource + +@OptIn(ExperimentalCoroutinesApi::class) +class AsyncActionIndicatorTest { + private val showDelay = 300.milliseconds + private val minDisplay = 500.milliseconds + + @Test + fun `loader is not shown before the show delay elapses`() = runTest { + val state = AsyncIndicatorState() + val action = mutableStateOf>(AsyncAction.Uninitialized) + indicatorFlow(action, state).test { + action.value = AsyncAction.Loading + advanceTimeBy(100.milliseconds) + runCurrent() + assertThat(state.currentAnimationState.targetState).isFalse() + // Once the show delay has fully elapsed, the loader is shown. + advanceTimeBy(300.milliseconds) + runCurrent() + assertThat(state.currentAnimationState.targetState).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loader is never shown when the operation completes before the show delay`() = runTest { + val state = AsyncIndicatorState() + val action = mutableStateOf>(AsyncAction.Uninitialized) + indicatorFlow(action, state).test { + action.value = AsyncAction.Loading + advanceTimeBy(100.milliseconds) + runCurrent() + action.value = AsyncAction.Success(Unit) + advanceTimeBy(1000.milliseconds) + runCurrent() + assertThat(state.currentAnimationState.targetState).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `failure is shown immediately, without waiting for the show delay`() = runTest { + val state = AsyncIndicatorState() + val action = mutableStateOf>(AsyncAction.Uninitialized) + indicatorFlow(action, state).test { + action.value = AsyncAction.Failure(Exception("failed")) + runCurrent() + assertThat(state.currentAnimationState.targetState).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loader stays visible for the minimum duration once shown`() = runTest { + val state = AsyncIndicatorState() + val action = mutableStateOf>(AsyncAction.Uninitialized) + val timeSource = TestTimeSource() + indicatorFlow(action, state, timeSource).test { + // Show the loader. + action.value = AsyncAction.Loading + advanceTimeBy(400.milliseconds) + runCurrent() + assertThat(state.currentAnimationState.targetState).isTrue() + // 200ms of display has already elapsed when the operation succeeds. + timeSource += 200.milliseconds + action.value = AsyncAction.Success(Unit) + runCurrent() + // Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible. + advanceTimeBy(200.milliseconds) + runCurrent() + assertThat(state.currentAnimationState.targetState).isTrue() + // Past the minimum display duration -> cleared. + advanceTimeBy(200.milliseconds) + runCurrent() + assertThat(state.currentAnimationState.targetState).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + private fun indicatorFlow( + action: State>, + state: AsyncIndicatorState, + timeSource: TimeSource = TimeSource.Monotonic, + ): Flow = moleculeFlow(RecompositionMode.Immediate) { + AsyncActionIndicatorEffect( + asyncAction = action.value, + state = state, + showDelay = showDelay, + minDisplayDuration = minDisplay, + failureDuration = minDisplay, + loading = {}, + failure = {}, + timeSource = timeSource, + ) + state.currentAnimationState.targetState + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformNormalisationTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformNormalisationTest.kt new file mode 100644 index 0000000000..23557704ce --- /dev/null +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/components/media/WaveformNormalisationTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector 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.libraries.designsystem.components.media + +import com.google.common.truth.Truth.assertThat +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test + +class WaveformNormalisationTest { + @Test + fun `an empty waveform is padded to the requested number of samples`() { + val result = persistentListOf().normalisedData(30) + assertThat(result).hasSize(30) + assertThat(result.toSet()).containsExactly(0f) + } + + @Test + fun `an empty waveform stays empty when there is no room to display it`() { + assertThat(persistentListOf().normalisedData(0)).isEmpty() + assertThat(persistentListOf().normalisedData(-1)).isEmpty() + } + + @Test + fun `a waveform shorter than the display count is kept as it is`() { + val waveform = persistentListOf(0.1f, 0.2f, 0.3f) + assertThat(waveform.normalisedData(30)).containsExactly(0.1f, 0.2f, 0.3f).inOrder() + } + + @Test + fun `a waveform longer than the display count is downsampled`() { + val waveform = persistentListOf(0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f) + assertThat(waveform.normalisedData(3)).hasSize(3) + } +} diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt index a20b53a480..ac83b49533 100644 --- a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt @@ -11,7 +11,14 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis fun interface EmojiPickerPresenter : Presenter { + /** + * Creates a presenter for one picker instance. + */ fun interface Factory { + /** + * @param getRecentEmojis how the picker sources its recent tab; pass [io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis] + * for a picker that should have no recent tab at all. + */ fun create(getRecentEmojis: GetRecentEmojis): EmojiPickerPresenter } } diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt index b3784b3c42..b273f41dae 100644 --- a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt @@ -13,9 +13,16 @@ import androidx.compose.ui.Modifier import io.element.android.emojibasebindings.Emoji import kotlinx.collections.immutable.ImmutableSet +/** + * Renders the emoji picker, so that callers can embed it without depending on its implementation. + * + * See [NoOpEmojiPickerRenderer] for a renderer that draws nothing, used where the picker is disabled. + */ @Immutable interface EmojiPickerRenderer { /** + * Draws the picker. + * * @param state opaque state produced by [EmojiPickerPresenter.present]. * @param onSelectEmoji invoked when the user taps an emoji (including a skin-tone variant). * @param selectedEmojis emojis to visually mark as already selected. diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/AddRecentEmoji.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/AddRecentEmoji.kt index 5865568511..af2a1dfd7d 100644 --- a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/AddRecentEmoji.kt +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/AddRecentEmoji.kt @@ -8,6 +8,12 @@ package io.element.android.libraries.emoji.api.recentemojis +/** + * Records that an emoji was just used, so it surfaces in the picker's recent list; see [GetRecentEmojis] for the read side. + */ fun interface AddRecentEmoji { + /** + * @param emoji the emoji that was just used, as its unicode representation. + */ suspend operator fun invoke(emoji: String): Result } diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/GetRecentEmojis.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/GetRecentEmojis.kt index cf6015d799..276b0e7eba 100644 --- a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/GetRecentEmojis.kt +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/recentemojis/GetRecentEmojis.kt @@ -15,6 +15,7 @@ import kotlinx.collections.immutable.persistentListOf * Returns the list of recently used emojis for reactions. */ fun interface GetRecentEmojis { + /** Returns the recently used emojis, most recent first; see [EmptyGetRecentEmojis] for a picker with no recent tab. */ suspend operator fun invoke(): Result> } diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 1cb274e6c0..59a1ad6575 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -36,5 +36,5 @@ dependencies { implementation(libs.matrix.emojibase.bindings) testImplementation(projects.libraries.matrix.test) - testCommonDependencies(libs) + testCommonDependencies(libs, includeTestComposeView = true) } diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt index 94eaea1595..88faa3a19a 100644 --- a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt @@ -62,7 +62,7 @@ internal fun EmojiPickerView( Column(modifier) { SearchBar( - modifier = Modifier.padding(bottom = 10.dp), + modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp), queryState = state.searchQuery, resultState = state.searchResults, active = state.isSearchActive, diff --git a/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt new file mode 100644 index 0000000000..4c0d7949cb --- /dev/null +++ b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerViewTest.kt @@ -0,0 +1,72 @@ +/* + * 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(ExperimentalTestApi::class) + +package io.element.android.libraries.emoji.impl.picker + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEqualTo +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import androidx.compose.ui.unit.dp +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test +import org.robolectric.annotation.Config + +private val PICKER_HEIGHT = 600.dp + +class EmojiPickerViewTest : RobolectricTest() { + @Config(qualifiers = "w400dp-h800dp") + @Test + fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest { + setEmojiPickerView() + onNode(hasScrollToIndexAction()) + .getBoundsInRoot() + .bottom + .assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom") + } +} + +private fun AndroidComposeUiTest.setEmojiPickerView() { + setContent { + Box(Modifier.size(400.dp, PICKER_HEIGHT)) { + EmojiPickerView( + state = aDefaultEmojiPickerState( + isSearchActive = true, + searchQuery = "smile", + searchResults = SearchBarResultState.Results( + persistentListOf( + Emoji( + hexcode = "0x00", + label = "grinning face", + tags = persistentListOf("grinning"), + shortcodes = persistentListOf("smile"), + unicode = "😀", + skins = null, + ), + ) + ), + ), + onSelectEmoji = {}, + selectedEmojis = persistentSetOf(), + modifier = Modifier.fillMaxSize(), + ) + } + } +} diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt index 67994f18b9..986e5dfe32 100644 --- a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/PinnedMessagesBannerFormatter.kt @@ -10,6 +10,12 @@ package io.element.android.libraries.eventformatter.api import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem +/** + * Formats the short summary of a pinned event shown in the banner at the top of a room. + */ interface PinnedMessagesBannerFormatter { + /** + * @param event the pinned event to describe. + */ fun format(event: EventTimelineItem): CharSequence } diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt index abb5cc929d..7ddc65f57b 100644 --- a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/RoomLatestEventFormatter.kt @@ -10,7 +10,25 @@ package io.element.android.libraries.eventformatter.api import io.element.android.libraries.matrix.api.roomlist.LatestEventValue +/** + * Formats the one-line preview of a room's latest event, as shown in the room list. + */ interface RoomLatestEventFormatter { + /** + * Formats an event that is still local, i.e. queued or being sent. + * + * @param latestEvent the local event to describe. + * @param isDmRoom whether the room is a direct message, which lets the sender name be left out. + * @return the preview text, or `null` when this kind of event should not be previewed. + */ fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? + + /** + * Formats an event that has been received from the server. + * + * @param latestEvent the remote event to describe. + * @param isDmRoom whether the room is a direct message, which lets the sender name be left out. + * @return the preview text, or `null` when this kind of event should not be previewed. + */ fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? } diff --git a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt index be601fb82a..67d263f168 100644 --- a/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt +++ b/libraries/eventformatter/api/src/main/kotlin/io/element/android/libraries/eventformatter/api/TimelineEventFormatter.kt @@ -13,7 +13,16 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName +/** + * Turns a state or membership event into the human readable sentence shown in the timeline, such as "Alice joined the room". + */ interface TimelineEventFormatter { + /** + * Convenience overload that reads the sender and ownership from the timeline item itself. + * + * @param event the timeline item to describe. + * @return the sentence to display, or `null` when this kind of event is not rendered as text. + */ fun format(event: EventTimelineItem): CharSequence? { return format( content = event.content, @@ -22,5 +31,13 @@ interface TimelineEventFormatter { senderDisambiguatedDisplayName = event.senderProfile.getDisambiguatedDisplayName(event.sender), ) } + + /** + * @param content the event content to describe. + * @param isOutgoing whether the event was sent by the current user, which selects the first-person wording. + * @param sender the user who sent the event. + * @param senderDisambiguatedDisplayName the sender's display name, already suffixed with their id when it is ambiguous in the room. + * @return the sentence to display, or `null` when this kind of event is not rendered as text. + */ fun format(content: EventContent, isOutgoing: Boolean, sender: UserId, senderDisambiguatedDisplayName: String): CharSequence? } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 1c5cd72977..e7bbdb1e7f 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -135,7 +135,7 @@ class DefaultRoomLatestEventFormatter( val message = when (val messageType: MessageType = type) { // Doesn't need a prefix is EmoteMessageType -> { - return "* $senderDisambiguatedDisplayName ${messageType.body}" + return "* ${senderDisambiguatedDisplayName.bidiIsolate()} ${messageType.body}" } is TextMessageType -> { messageType.toPlainText(permalinkParser) @@ -189,7 +189,7 @@ class DefaultRoomLatestEventFormatter( if (isOutgoing) { sp.getString(CommonStrings.common_you) } else { - senderDisambiguatedDisplayName + senderDisambiguatedDisplayName.bidiIsolate() } ) } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt index 1a04893e07..f0965e95af 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt @@ -14,6 +14,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle +private const val FIRST_STRONG_ISOLATE = 0x2068 +private const val POP_DIRECTIONAL_ISOLATE = 0x2069 + +/** Wraps the string between Unicode isolate characters so its own direction cannot reorder the text around it. */ +internal fun String.bidiIsolate(): String = Char(FIRST_STRONG_ISOLATE) + this + Char(POP_DIRECTIONAL_ISOLATE) + internal fun CharSequence.prefixWith(prefix: String): AnnotatedString { return buildAnnotatedString { withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index 791a363f77..ecd0509269 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -91,7 +91,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { assertThat(result).isEqualTo(expected) } else { assertThat(result).isInstanceOf(AnnotatedString::class.java) - assertThat(result.toString()).isEqualTo("$senderName: $expected") + assertThat(result.toString()).isEqualTo("⁨$senderName⁩: $expected") } } } @@ -103,13 +103,13 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { val info = ImageInfo(null, null, null, null, null, null, null) val message = createLatestEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url"))) val result = formatter.format(message, false) - val expectedBody = someoneElseId.value + ": Sticker: a sticker body" + val expectedBody = "⁨" + someoneElseId.value + "⁩: Sticker: a sticker body" // Check we have formatting assertThat(result is AnnotatedString).isTrue() // And there is a bold span for the 'Sticker' part val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold } assertThat(boldSpanStyle).isNotNull() - val spanStart = someoneElseId.value.length + 2 + val spanStart = someoneElseId.value.length + 4 assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 7) assertThat(result.toString()).isEqualTo(expectedBody) } @@ -130,7 +130,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { assertThat(result).isEqualTo(expected) } else { assertThat(result).isInstanceOf(AnnotatedString::class.java) - assertThat(result.toString()).isEqualTo("$senderName: $expected") + assertThat(result.toString()).isEqualTo("⁨$senderName⁩: $expected") } } } @@ -152,12 +152,29 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected) } else { assertWithMessage("$type does not create an AnnotatedString").that(result).isInstanceOf(AnnotatedString::class.java) - assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected") + assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("⁨$senderName⁩: $expected") } } } } + @Test + @Config(qualifiers = "en") + fun `a right to left sender name is isolated so it cannot reorder the preview`() { + val senderName = "مرحبا" + val body = "Hello" + val message = createLatestEvent( + sentByYou = false, + senderDisplayName = senderName, + content = MessageContent(body, null, false, null, TextMessageType(body, null)), + ) + val result = formatter.format(message, false) + assertThat(result.toString()).isEqualTo("⁨$senderName⁩: $body") + val boldSpanStyle = (result as AnnotatedString).spanStyles.first { it.item.fontWeight == FontWeight.Bold } + assertThat(boldSpanStyle.start).isEqualTo(0) + assertThat(boldSpanStyle.end).isEqualTo(senderName.length + 2) + } + // region Message contents @Test @@ -166,7 +183,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { testMessageContents( sentByYou = false, senderName = "Alice", - expectedPrefix = "Alice", + expectedPrefix = "⁨Alice⁩", ) } @@ -233,7 +250,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { is StickerMessageType -> "Sticker: Shared body" is FileMessageType -> "File: Shared body" is LocationMessageType -> "Shared location" - is EmoteMessageType -> "* $senderName ${type.body}" + is EmoteMessageType -> "* ⁨$senderName⁩ ${type.body}" is TextMessageType, is NoticeMessageType, is OtherMessageType -> body @@ -275,7 +292,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { is TextMessageType, is NoticeMessageType, is OtherMessageType -> "$expectedPrefix: $body" - is EmoteMessageType -> "* $senderName ${type.body}" + is EmoteMessageType -> "* ⁨$senderName⁩ ${type.body}" } val shouldCreateAnnotatedString = when (type) { is VideoMessageType -> true @@ -944,7 +961,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { assertThat(formatter.format(mineContentEvent, false).toString()).isEqualTo("You: Poll: Do you like polls?") val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent) - assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("Bob: Poll: Do you like polls?") + assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("⁨Bob⁩: Poll: Do you like polls?") val result = formatter.format(contentEvent, false) // Check we have formatting @@ -952,7 +969,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() { // And there is a bold span for the 'Poll' part val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold } assertThat(boldSpanStyle).isNotNull() - val spanStart = "Bob".length + 2 + val spanStart = "Bob".length + 4 assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 4) } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt index 395fd04c6c..4fd8706d89 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/Feature.kt @@ -10,6 +10,9 @@ package io.element.android.libraries.featureflag.api import io.element.android.libraries.core.meta.BuildMeta +/** + * Describes one feature flag: how it is identified, how it is presented in the developer options, and what it defaults to. + */ interface Feature { /** * Unique key to identify the feature. diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt index 6a8cb2fd9d..3dc791317f 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlagService.kt @@ -11,6 +11,9 @@ package io.element.android.libraries.featureflag.api import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +/** + * Reads and, where a mutable provider is registered, changes the state of the feature flags. + */ interface FeatureFlagService { /** * @param feature the feature to check for diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeaturesProvider.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeaturesProvider.kt new file mode 100644 index 0000000000..a342ccdd9b --- /dev/null +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeaturesProvider.kt @@ -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.libraries.featureflag.api + +/** + * Provides a list of available [Feature]s that the application supports. + * Used by [FeatureFlagService.getAvailableFeatures] to obtain a list of features + * that can be presented to the user. + * + * Multiple instances can exist within the application, and all instances are polled + * to provide features for the list. This means that additional features can be provided + * by compile-time extensions. + */ +fun interface FeaturesProvider { + fun provide(): List +} diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt index 74967a574d..675cf7855c 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt @@ -14,6 +14,7 @@ import dev.zacsweers.metro.SingleIn import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeaturesProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.flowOf class DefaultFeatureFlagService( private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>, private val buildMeta: BuildMeta, - private val featuresProvider: FeaturesProvider, + private val featuresProviders: Set, ) : FeatureFlagService { override fun isFeatureEnabledFlow(feature: Feature): Flow { return if (feature.isFinished) { @@ -47,7 +48,7 @@ class DefaultFeatureFlagService( includeFinishedFeatures: Boolean, isInLabs: Boolean, ): List { - return featuresProvider.provide().filter { flag -> + return featuresProviders.flatMap { provider -> provider.provide() }.filter { flag -> (includeFinishedFeatures || !flag.isFinished) && flag.isInLabs == isInLabs } diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeaturesProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeaturesProvider.kt similarity index 78% rename from libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeaturesProvider.kt rename to libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeaturesProvider.kt index e24ae66783..c96a781d82 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/FeaturesProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeaturesProvider.kt @@ -9,15 +9,12 @@ package io.element.android.libraries.featureflag.impl import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.ContributesIntoSet import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.FeaturesProvider -fun interface FeaturesProvider { - fun provide(): List -} - -@ContributesBinding(AppScope::class) +@ContributesIntoSet(AppScope::class) class DefaultFeaturesProvider : FeaturesProvider { override fun provide(): List = FeatureFlags.entries } diff --git a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt index 00bc687bb9..0ed6d8eafd 100644 --- a/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt +++ b/libraries/featureflag/impl/src/test/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagServiceTest.kt @@ -12,6 +12,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.featureflag.api.Feature +import io.element.android.libraries.featureflag.api.FeaturesProvider import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.matrix.test.core.aBuildMeta import kotlinx.coroutines.test.runTest @@ -166,5 +167,5 @@ private fun createDefaultFeatureFlagService( ) = DefaultFeatureFlagService( providers = providers, buildMeta = buildMeta, - featuresProvider = { features } + featuresProviders = setOf(FeaturesProvider { features }) ) diff --git a/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt b/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt index 0409a0f846..d2fde82d8a 100644 --- a/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt +++ b/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt @@ -15,9 +15,11 @@ import androidx.compose.runtime.State * A set of State to observe to display or not the indicators in the UI. */ interface IndicatorService { + /** Whether to show the red dot on the room list top bar, which aggregates the reasons the user should open the settings. */ @Composable fun showRoomListTopBarIndicator(): State + /** Whether to show the red dot on the chat backup setting, i.e. when recovery needs the user's attention. */ @Composable fun showSettingChatBackupIndicator(): State } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt index e914656f5f..fe05b88dc4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/HomeserverCapabilitiesProvider.kt @@ -10,6 +10,8 @@ package io.element.android.libraries.matrix.api /** * Provides information about the capabilities of the homeserver. * + * The getters read a snapshot held by the SDK rather than querying the server on each call, so [refresh] must be used to pick up server-side changes. + * * Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation */ interface HomeserverCapabilitiesProvider { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 0dd8821a4f..b83d96af96 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -54,59 +54,243 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import java.util.Optional -interface MatrixClient { +/** + * Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations. + * + * One instance exists per session, created on login or session restore and torn down by [logout], [clearCache] or account deactivation. + * Suspending members here report failures through a [Result] instead of throwing, and run their work off the main thread. + */ +interface MatrixClient : UrlContentFetcher { + /** Identifier of the logged in user; a [SessionId] and a [UserId] are the same value. */ val sessionId: SessionId + + /** Identifier of this device, as assigned by the homeserver when the session was created. */ val deviceId: DeviceId /** Base URL of the homeserver this client is connected to. */ val homeserverUrl: String + + /** Directories holding this session's databases, caches and downloaded media. */ val sessionPaths: SessionPaths + + /** + * Display name and avatar of the logged in user. + * Seeded from the local session store so a value is available before the first network fetch, then kept up to date by the SDK. + */ val userProfile: StateFlow + + /** Gives access to the list of rooms the user is a member of, or has been invited to. */ val roomListService: RoomListService + + /** Gives access to the spaces the user belongs to and their hierarchy. */ val spaceService: SpaceService + + /** Controls the sync loop of this session and exposes its state. */ val syncService: SyncService + + /** Handles verifying this session against the user's other devices or their recovery key. */ val sessionVerificationService: SessionVerificationService + + /** Manages the push gateways (pushers) registered on the homeserver for this session. */ val pushersService: PushersService + + /** Resolves incoming push notifications into renderable events. */ val notificationService: NotificationService + + /** Reads and writes the push notification rules of the account. */ val notificationSettingsService: NotificationSettingsService + + /** Gives access to end-to-end encryption state, recovery and key backup. */ val encryptionService: EncryptionService + + /** Gives access to the public room directory of the homeserver. */ val roomDirectoryService: RoomDirectoryService /** Whether this live client was built with a message search index attached. */ val isMessageSearchAvailable: Boolean + + /** Searches messages in the local index; only usable when [isMessageSearchAvailable] is `true`. */ val messageSearchService: MessageSearchService + + /** Reads and writes the account settings controlling whether media previews and avatars are shown. */ val mediaPreviewService: MediaPreviewService + + /** Downloads media content, thumbnails and files from the homeserver, caching them on disk. */ val matrixMediaLoader: MatrixMediaLoader + + /** + * Coroutine scope tied to the lifetime of this session, to be used for work that must stop when the session goes away. + * It is cancelled when the session is logged out or otherwise destroyed, so never use it for work that must outlive the session. + */ val sessionCoroutineScope: CoroutineScope + + /** The users the logged in user has ignored; starts empty and is updated by the SDK, including with an initial value. */ val ignoredUsersFlow: StateFlow> + + /** Notifies of membership changes the current user makes, so that callers can react to leaving or joining a room. */ val roomMembershipObserver: RoomMembershipObserver + + /** Emits updates about the live location beacons published by the current user. */ val ownBeaconInfoUpdates: Flow + + /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + + /** + * Returns the room with the given id if the user has joined it, along with its live [Timeline]. + * A new instance is created on each call and the caller owns it, so it must be closed once no longer needed. + * + * @param roomId the id of the room to open. + * @return the room, or `null` if it is unknown to the client or the user is not joined. + */ suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? + + /** + * Returns the room with the given id whatever the current membership is, without building a timeline. + * As with [getJoinedRoom], a new instance is created on each call and must be closed by the caller. + * + * @param roomId the id of the room to open. + * @return the room, or `null` if it is unknown to the client. + */ suspend fun getRoom(roomId: RoomId): BaseRoom? + + /** + * Looks for an existing direct message room shared with [userId]. + * + * @param userId the other member of the direct message room. + * @return the room id, or `null` if no direct message room exists with this user. + */ suspend fun findDM(userId: UserId): Result + + /** + * Returns the ids of every room the user has joined, as currently known by the client. + * Invited, knocked and left rooms are excluded. + */ suspend fun getJoinedRoomIds(): Result> + + /** + * Adds [userId] to the ignore list of the account, which hides their events and makes [ignoredUsersFlow] emit. + * + * @param userId the user to ignore. + */ suspend fun ignoreUser(userId: UserId): Result + + /** + * Removes [userId] from the ignore list of the account, which makes [ignoredUsersFlow] emit. + * + * @param userId the user to stop ignoring. + */ suspend fun unignoreUser(userId: UserId): Result + + /** + * Creates a room, applying the app's default power levels on top of [createRoomParams]. + * Waits a short while for the new room to come back through sync, but a timeout there is only logged: the result is still a success. + * + * @param createRoomParams the name, topic, preset, invitees and other settings of the room to create. + */ suspend fun createRoom(createRoomParams: CreateRoomParameters): Result + + /** + * Creates a direct message room with [userId], as a private, invite-only room with the user already invited. + * This does not check whether such a room already exists; use [findDM] first if you want to reuse one. + * + * @param userId the user to start the direct message with. + * @param isEncrypted whether the room should have end-to-end encryption enabled. + */ suspend fun createDM(userId: UserId, isEncrypted: Boolean): Result + + /** + * Fetches the current profile of any user from the homeserver, bypassing the local cache. + * + * @param userId the user whose profile is requested. + */ suspend fun getProfile(userId: UserId): Result + + /** + * Searches the homeserver's user directory. + * + * @param searchTerm the text to look for in user ids and display names. + * @param limit the maximum number of results to return. + */ suspend fun searchUsers(searchTerm: String, limit: Long): Result + + /** + * Changes the display name of the logged in user on the homeserver. + * + * @param displayName the new display name. + */ suspend fun setDisplayName(displayName: String): Result + + /** + * Uploads [data] as the new avatar of the logged in user. + * + * @param mimeType the MIME type of the image, for instance `image/jpeg`. + * @param data the raw bytes of the image. + */ suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result + + /** Removes the avatar of the logged in user. */ suspend fun removeAvatar(): Result + + /** + * Sets the `m.status` profile field of the logged in user (MSC4426), refreshing [userProfile] if the server does not push the change itself. + * + * @param status the status to publish. + */ suspend fun setUserStatus(status: UserStatus): Result /** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */ suspend fun clearUserStatus(): Result + + /** Whether the homeserver advertises support for user status (MSC4426). */ + suspend fun isUserStatusSupported(): Result + + /** + * Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. + * + * @param enabled whether the status should be maintained automatically. + */ + fun enableAutomaticCallStatus(enabled: Boolean) + + /** + * Joins a room the client already knows about, then waits briefly for the new membership to arrive through sync. + * The returned [RoomInfo] is `null` when that wait times out, which is not treated as a failure: the join itself succeeded. + * + * @param roomId the id of the room to join. + */ suspend fun joinRoom(roomId: RoomId): Result + + /** + * Joins a room by id or by alias, resolving it through [serverNames] when the client does not know it yet. + * As with [joinRoom], the returned [RoomInfo] is `null` if the membership did not arrive through sync in time. + * + * @param roomIdOrAlias the id or the alias of the room to join. + * @param serverNames servers to ask about the room, used when it cannot be resolved locally. + */ suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result + + /** + * Sends a request to join a room whose join rule is knock, then waits briefly for the knocked membership to arrive through sync. + * As with [joinRoom], the returned [RoomInfo] is `null` if that wait times out. + * + * @param roomIdOrAlias the id or the alias of the room to knock on. + * @param message the reason shown to the room moderators; may be empty. + * @param serverNames servers to ask about the room, used when it cannot be resolved locally. + */ suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result + + /** + * Returns the number of bytes this session occupies on disk that [clearCache] would be able to reclaim. + * This covers the cache directory, the state database and the message search index, but not the crypto database. + */ suspend fun getCacheSize(): Long + + /** Returns the size of each individual store the SDK maintains for this session, as reported by the SDK. */ suspend fun getDatabaseSizes(): Result /** - * Will close the client and delete the cache data. + * Deletes the cached data of this session and destroys the client. + * The instance is unusable afterwards, so callers are expected to restart the session, unlike with [logout] the session itself is kept. */ suspend fun clearCache() @@ -119,29 +303,56 @@ interface MatrixClient { suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) /** - * Retrieve the user profile, will also eventually emit a new value to [userProfile]. + * Retrieve the profile of the logged in user, emitting it to [userProfile] and persisting it in the local session store on success. */ suspend fun getUserProfile(): Result + + /** + * Returns the web URL where the user can manage their account on the authentication provider, or `null` if the homeserver exposes none. + * + * @param action an optional section of the account management page to open directly, such as changing the password. + */ suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result + + /** + * Uploads arbitrary content to the homeserver's media repository. + * + * @param mimeType the MIME type of the content being uploaded. + * @param data the raw bytes to upload. + * @return the `mxc://` URI the content is now available at. + */ suspend fun uploadMedia(mimeType: String, data: ByteArray): Result /** * Get a room info flow for a given room ID. - * The flow will emit a new value whenever the room info is updated. - * The flow will emit Optional.empty item if the room is not found. + * The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found. + * + * @param roomId the id of the room to observe. */ fun getRoomInfoFlow(roomId: RoomId): Flow> + /** + * Whether [userId] is the logged in user, `false` for a `null` value. + * + * @param userId the user id to compare against this session's user. + */ fun isMe(userId: UserId?) = userId == sessionId + /** + * Adds a room to the account's list of recently visited rooms, which backs the room suggestions when sharing or forwarding. + * + * @param roomId the id of the room that was just visited. + */ suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result + + /** Returns the recently visited rooms of the account, most recent first. */ suspend fun getRecentlyVisitedRooms(): Result> /** * Resolves the given room alias to a roomID (and a list of servers), if possible. - * @param roomAlias the room alias to resolve - * @return the resolved room alias if any, an empty result if not found,or an error if the resolution failed. * + * @param roomAlias the room alias to resolve. + * @return the resolved room alias if any, an empty result if not found, or an error if the resolution failed. */ suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result> @@ -152,6 +363,8 @@ interface MatrixClient { * event with it failed (e.g. sending an event via the Timeline), * so it's required to manually re-enable it as soon as * connectivity is back on the device. + * + * @param enabled whether the send queues of every room should be enabled. */ suspend fun setAllSendQueuesEnabled(enabled: Boolean) @@ -167,13 +380,11 @@ interface MatrixClient { */ fun userIdServerName(): String - /** - * Execute generic GET requests through the SDKs internal HTTP client. - */ - suspend fun getUrl(url: String): Result - /** * Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet. + * + * @param roomIdOrAlias the id or the alias of the room to preview. + * @param serverNames servers to ask about the room, used when it cannot be resolved locally. */ suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result @@ -182,11 +393,21 @@ interface MatrixClient { */ suspend fun currentSlidingSyncVersion(): Result + /** Whether this account can be deactivated from the app, which depends on the authentication method used by the homeserver. */ fun canDeactivateAccount(): Boolean + + /** + * Deactivates the account on the homeserver, then destroys this client and deletes every local trace of the session. + * The password is only sent if the homeserver asks for re-authentication, and on failure the session is left untouched. + * + * @param password the account password, required to confirm the deactivation. + * @param eraseData whether the homeserver should also erase the messages the user sent. + */ suspend fun deactivateAccount(password: String, eraseData: Boolean): Result /** - * Check if the user can report a room. + * Whether the homeserver supports reporting a whole room, rather than individual events. + * Returns `false` if the capability cannot be determined. */ suspend fun canReportRoom(): Boolean @@ -207,14 +428,34 @@ interface MatrixClient { /** * Adds an emoji to the list of recent emoji reactions for this account. + * + * @param emoji the emoji that was just used. */ suspend fun addRecentEmoji(emoji: String): Result + /** + * Returns the raw JSON content of the global account data event of type [eventType], or null if it is not set. + * + * @param eventType the Matrix event type of the account data to read, for instance `m.direct`. + */ + suspend fun getAccountData(eventType: String): Result + + /** + * Sets the global account data event of type [eventType] to the raw JSON [content]. + * + * @param eventType the Matrix event type of the account data to write. + * @param content the new content, as a raw JSON object. + */ + suspend fun setAccountData(eventType: String, content: String): Result + /** * Marks the room with the provided [roomId] as read, sending a fully read receipt for [eventId]. * * This method should be used with caution as providing the [eventId] ourselves can result in incorrect read receipts. * Use [Timeline.markAsRead] instead when possible. + * + * @param roomId the id of the room to mark as read. + * @param eventId the event up to which the room is considered read. */ suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result @@ -241,19 +482,16 @@ interface MatrixClient { /** * Performs a database optimization that should flush cached data and improve performance. + * A periodic background job already calls this for every session, so callers rarely need to trigger it by hand. */ suspend fun performDatabaseVacuum(): Result - /** - * Returns the URL of the map style configured on the server, if any. - */ - suspend fun getMapStyleUrl(): Result - /** * Resets the cached client `well-known` config by the SDK. */ suspend fun resetWellKnownConfig(): Result + /** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */ fun homeserverCapabilities(): HomeserverCapabilitiesProvider } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt index 54f70845fa..08073d0cc0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClientProvider.kt @@ -10,18 +10,26 @@ package io.element.android.libraries.matrix.api import io.element.android.libraries.matrix.api.core.SessionId +/** + * In-memory cache of the [MatrixClient] of every logged in session. + * + * Prefer injecting the [MatrixClient] directly: this provider is meant for the entry points that only know a [SessionId], such as push handling and workers. + */ interface MatrixClientProvider { /** - * Can be used to get or restore a MatrixClient with the given [SessionId]. - * If a [MatrixClient] is already in memory, it'll return it. Otherwise it'll try to restore one. - * Most of the time you want to use injected constructor instead of retrieving a MatrixClient with this provider. + * Returns the cached [MatrixClient] for [sessionId], restoring the session from storage and starting its sync if it is not in memory yet. + * Concurrent calls are serialised, so only one restore happens per session. + * + * @param sessionId the [SessionId] of the session to get or restore. + * @return the client, or a failure if the session could not be restored. */ suspend fun getOrRestore(sessionId: SessionId): Result /** - * Can be used to retrieve an existing [MatrixClient] with the given [SessionId]. - * @param sessionId the [SessionId] of the [MatrixClient] to retrieve. - * @return the [MatrixClient] if it exists. + * Returns the [MatrixClient] for [sessionId] only if it is already in memory, without attempting to restore it. + * + * @param sessionId the [SessionId] of the session to retrieve. + * @return the cached client, or `null` if that session is not currently held in memory. */ fun getOrNull(sessionId: SessionId): MatrixClient? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt index 16f34807fc..939734e314 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkMetadata.kt @@ -8,6 +8,14 @@ package io.element.android.libraries.matrix.api +/** + * Exposes build information about the bundled `matrix-rust-sdk`. + * + * Mostly used to identify the exact SDK revision in bug reports and in the user agent. + */ interface SdkMetadata { + /** + * The full git SHA of the commit the bundled `matrix-rust-sdk` was built from. + */ val sdkGitSha: String } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt new file mode 100644 index 0000000000..1e3ffea154 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClient.kt @@ -0,0 +1,18 @@ +/* + * 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.libraries.matrix.api + +import java.lang.AutoCloseable + +/** + * A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs. + * + * Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated + * with it, such as temporary files. + */ +interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt new file mode 100644 index 0000000000..3bd22c64f3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/TemporaryMatrixClientFactory.kt @@ -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.libraries.matrix.api + +/** + * Factory to create a temporary [TemporaryMatrixClient] for a given home server URL. + */ +interface TemporaryMatrixClientFactory { + /** + * Builds an unauthenticated client pointing at [homeServerUrl], backed by a fresh set of temporary session directories. + * The caller owns the returned client and must close it to delete those directories. + * + * @param homeServerUrl the URL of the homeserver the client will talk to. + */ + suspend fun create(homeServerUrl: String): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt new file mode 100644 index 0000000000..a6fb234b86 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/UrlContentFetcher.kt @@ -0,0 +1,18 @@ +/* + * 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.libraries.matrix.api + +/** + * An interface to fetch content from a URL using a simple GET request. + */ +fun interface UrlContentFetcher { + /** + * Execute generic GET requests through the SDKs internal HTTP client. + */ + suspend fun getUrl(url: String): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt index fde02fb2c6..1ca9adfaca 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt @@ -9,6 +9,14 @@ package io.element.android.libraries.matrix.api.analytics import io.element.android.libraries.matrix.api.core.SessionId +/** + * Reads the on-disk size of a session's SDK stores, so they can be reported as analytics. + */ fun interface GetDatabaseSizesUseCase { + /** + * Returns the size of each store of the given session. + * + * @param sessionId the session whose stores are measured. + */ operator fun invoke(sessionId: SessionId): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index 04d1d13593..3ff1eaa337 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -16,23 +16,41 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId +/** + * Creates and restores sessions: password login, OAuth login, QR code login, and importing a session from another client. + * + * [setHomeserver] must be called before any login attempt, since it is what tells the SDK which server to authenticate against. + * Every successful login stores the session and notifies the listeners registered through [listenToNewMatrixClients]. + */ interface MatrixAuthenticationService { /** * Restore a session from a [sessionId]. * Do not restore anything it the access token is not valid anymore. * Generally this method should not be used directly, prefer using [MatrixClientProvider.getOrRestore] instead. + * + * @param sessionId the session to restore. */ suspend fun restoreSession(sessionId: SessionId): Result /** * Set the homeserver to use for authentication, and return its details. + * + * @param homeserver the homeserver URL or server name to authenticate against. */ suspend fun setHomeserver(homeserver: String): Result + /** + * Logs in with a password on the homeserver previously set through [setHomeserver]. + * + * @param username the user id or its local part. + * @param password the account password. + */ suspend fun login(username: String, password: String): Result /** * Import a session that was created using another client, for instance Element Web. + * + * @param externalSession the tokens and identifiers of the session to import. */ suspend fun importCreatedSession(externalSession: ExternalSession): Result @@ -42,6 +60,9 @@ interface MatrixAuthenticationService { /** * Get the OAuth url to display to the user. + * + * @param prompt whether the authentication server should show the sign-in or the account-creation page. + * @param loginHint the user identifier to pre-fill on that page, or `null` to leave it empty. */ suspend fun getOAuthUrl( prompt: OAuthPrompt, @@ -55,11 +76,17 @@ interface MatrixAuthenticationService { /** * Set the existing data about Element Classic session, if any. + * + * @param session the session found in a local Element Classic install, or `null` when there is none to migrate. */ fun setElementClassicSession(session: ElementClassicSession?) /** * Check if the provided secrets from Element Classic session contain a key backup. + * + * @param userId the user the secrets belong to. + * @param secrets the raw secrets exported from Element Classic. + * @param backupInfo the raw backup description exported alongside them. */ fun doSecretsContainBackupKey( userId: UserId, @@ -69,11 +96,23 @@ interface MatrixAuthenticationService { /** * Attempt to log in using the [callbackUrl] provided by the OAuth page. + * + * @param callbackUrl the redirect URL the authentication server sent the user back to, carrying the authorization code. */ suspend fun loginWithOAuth(callbackUrl: String): Result + /** + * Logs in by acting on a QR code shown by an already signed in device, reporting each step of the exchange as it happens. + * + * @param qrCodeData the decoded QR code, obtained from [io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory]. + * @param progress called on every step of the flow, so the UI can follow along. + */ suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result - /** Listen to new Matrix clients being created on authentication. */ + /** + * Listen to new Matrix clients being created on authentication. + * + * @param lambda called with each client created by a successful login or session restore. + */ fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt index 3256d0545c..b3ee0878d5 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthConfig.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth import io.element.android.libraries.matrix.api.BuildConfig object OAuthConfig { + // This *can* be null if the build time config field is not set + @Suppress("UNNECESSARY_SAFE_CALL") val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let { // Concatenate the base URI and the path, ensuring there is exactly one '/' between them "${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthRedirectUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthRedirectUrlProvider.kt index 669d47501d..648884e930 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthRedirectUrlProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/OAuthRedirectUrlProvider.kt @@ -8,6 +8,10 @@ package io.element.android.libraries.matrix.api.auth +/** + * Provides the redirect URL registered for OAuth login, which the authentication server sends the user back to once they have signed in. + */ interface OAuthRedirectUrlProvider { + /** Returns the redirect URL, built from the app's custom URL scheme so that the system hands the callback back to the app. */ fun provide(): String } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt index 413f17af63..f800ad2ba1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt @@ -8,6 +8,12 @@ package io.element.android.libraries.matrix.api.auth.qrlogin +/** + * The decoded content of a login QR code, as produced by [MatrixQrCodeLoginDataFactory]. + * + * The underlying data is opaque to the app: it is passed back to the SDK to perform the login. + */ interface MatrixQrCodeLoginData { + /** The homeserver advertised by the QR code, or `null` when it does not carry one. */ fun serverName(): String? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt index b0e3089501..21d77b74f2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt @@ -8,6 +8,14 @@ package io.element.android.libraries.matrix.api.auth.qrlogin +/** + * Decodes the raw bytes of a scanned QR code into login data the SDK can use. + */ interface MatrixQrCodeLoginDataFactory { + /** + * Parses the scanned bytes, failing with a [QrCodeDecodeException] when they are not a valid Matrix login QR code. + * + * @param data the raw content read from the QR code. + */ fun parseQrCodeData(data: ByteArray): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt index a7abc0f797..5c8a75e384 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ProgressCallback.kt @@ -8,6 +8,15 @@ package io.element.android.libraries.matrix.api.core +/** + * Reports the progress of a long running transfer, such as a media upload or download. + */ interface ProgressCallback { + /** + * Called as the transfer advances, possibly many times and from a background thread. + * + * @param current the number of bytes transferred so far. + * @param total the total number of bytes to transfer. + */ fun onProgress(current: Long, total: Long) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt index f3b8904fff..02c8a85d58 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SendHandle.kt @@ -8,6 +8,10 @@ package io.element.android.libraries.matrix.api.core +/** + * A handle on an event sitting in a room's send queue, obtained from a timeline item that failed to send. + */ fun interface SendHandle { + /** Puts the event back in the send queue for another attempt. */ suspend fun retry(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index f82cdc1969..e036a198df 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -14,13 +14,32 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +/** + * Gives access to the end-to-end encryption state of the session: key backup, recovery, and the trust of other users' identities. + */ interface EncryptionService { + /** + * The state of the room key backup on the server. + * Reports [BackupState.WAITING_FOR_SYNC] for as long as the session is not actively syncing, since the real state is unknown until then. + */ val backupStateStateFlow: StateFlow + + /** + * The state of recovery, i.e. whether the secrets needed to restore the keys on a new device are available. + * As with [backupStateStateFlow], it reports a waiting-for-sync state until the session is syncing. + */ val recoveryStateStateFlow: StateFlow + + /** Progress of the [enableRecovery] call currently in flight, to be observed while that call is running. */ val enableRecoveryProgressStateFlow: StateFlow + + /** Whether this is the only device of the account, which matters because logging out of it can make the room keys unrecoverable. */ val isLastDevice: StateFlow + + /** Whether the account has another device this one could be verified against; it stays loading until the first answer arrives. */ val hasDevicesToVerifyAgainst: StateFlow> + /** Enables the room key backup on the server, so that the keys survive losing every device. */ suspend fun enableBackups(): Result /** @@ -39,12 +58,16 @@ interface EncryptionService { */ suspend fun resetRecoveryKey(): Result + /** Disables recovery, which deletes the secrets from server-side storage; the key backup itself is left in place. */ suspend fun disableRecovery(): Result + /** Whether a room key backup currently exists on the server, whether or not this device is the one using it. */ suspend fun doesBackupExistOnServer(): Result /** - * Note: accept both recoveryKey and passphrase. + * Restores the secrets from server-side storage, which unlocks the room key backup for this device. + * + * @param recoveryKey the recovery key, or the passphrase it was derived from; both are accepted. */ suspend fun recover(recoveryKey: String): Result @@ -72,6 +95,8 @@ interface EncryptionService { /** * Remember this identity, ensuring it does not result in a pin violation. + * + * @param userId the user whose current identity should be pinned. */ suspend fun pinUserIdentity(userId: UserId): Result @@ -81,6 +106,8 @@ interface EncryptionService { * Useful when a user that was verified is not anymore, but it is not * possible to re-verify immediately. This allows to restore communication by reverting the * user trust from verified to TOFU verified. + * + * @param userId the user whose verification should be withdrawn. */ suspend fun withdrawVerification(userId: UserId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt index ecb440de83..a3a518f9ce 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt @@ -7,6 +7,9 @@ package io.element.android.libraries.matrix.api.linknewdevice +/** + * Lets the user confirm the two-digit check code displayed by the device being linked, which proves the channel is not being intercepted. + */ interface CheckCodeSender { /** * Validates the given [code]. Returns true if the code is valid, false otherwise. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt new file mode 100644 index 0000000000..0a2ce273d5 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ContinuationMessageSender.kt @@ -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.libraries.matrix.api.linknewdevice + +/** + * Lets the user confirm or refuse a device linking request once they have approved it in the browser. + * + * Handed out with the step that is waiting for authorisation; exactly one of the two methods is meant to be called. + */ +interface ContinuationMessageSender { + /** Tells the other device that the user refused the linking, which ends the flow. */ + suspend fun cancel(): Result + + /** Tells the other device that the user approved the linking, so the flow can carry on. */ + suspend fun confirm(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt index d600c1a8bf..26c2ed04c4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt @@ -10,16 +10,32 @@ package io.element.android.libraries.matrix.api.linknewdevice import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException import kotlinx.coroutines.flow.StateFlow +/** + * Drives the linking of a new device that cannot scan a QR code, such as a desktop: this device scans the code the other one displays. + * + * Progress is reported entirely through [linkDesktopStep]; there is one handler per attempt, obtained from + * [io.element.android.libraries.matrix.api.MatrixClient.createLinkDesktopHandler]. + */ interface LinkDesktopHandler { + /** Each step of the flow, from establishing the secure channel to syncing the secrets to the new device. */ val linkDesktopStep: StateFlow + + /** + * Runs the whole flow from the QR code just scanned, suspending until it finishes or fails. + * A code that cannot be decoded is reported as an invalid-QR-code step rather than as a thrown error. + * + * @param data the raw content read from the QR code shown by the other device. + */ suspend fun handleScannedQrCode(data: ByteArray) } sealed interface LinkDesktopStep { data object Uninitialized : LinkDesktopStep data object Starting : LinkDesktopStep + data class WaitingForAuth( val verificationUri: String, + val continuationMessageSender: ContinuationMessageSender, ) : LinkDesktopStep data class EstablishingSecureChannel( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt index eb2d49b5b0..ae51eb1f0a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt @@ -9,8 +9,17 @@ package io.element.android.libraries.matrix.api.linknewdevice import kotlinx.coroutines.flow.Flow +/** + * Drives the linking of a new device that is able to scan a QR code, by showing one on this already signed in device. + * + * Progress is reported entirely through [linkMobileStep]; there is one handler per attempt, obtained from + * [io.element.android.libraries.matrix.api.MatrixClient.createLinkMobileHandler]. + */ interface LinkMobileHandler { + /** Each step of the flow, from generating the QR code to syncing the secrets to the new device. */ val linkMobileStep: Flow + + /** Runs the whole flow, suspending until it finishes or fails; the outcome is reported through [linkMobileStep]. */ suspend fun start() } @@ -22,7 +31,12 @@ sealed interface LinkMobileStep { data object Starting : LinkMobileStep data class QrReady(val data: String) : LinkMobileStep data object QrRotating : LinkMobileStep - data class WaitingForAuth(val verificationUri: String) : LinkMobileStep + + data class WaitingForAuth( + val verificationUri: String, + val continuationMessageSender: ContinuationMessageSender, + ) : LinkMobileStep + data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep data class Error(val errorType: ErrorType) : LinkMobileStep data object SyncingSecrets : LinkMobileStep diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt index a36849b812..28ae0762ff 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MatrixMediaLoader.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.matrix.api.media +/** + * Fetches media content from the homeserver, decrypting it on the way when the room is encrypted. + */ interface MatrixMediaLoader { /** * @param source to fetch the content for. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt index b5cefe897b..ae6035da88 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaFile.kt @@ -16,6 +16,7 @@ import java.io.File * When closed the file will be removed from the disk unless [persist] has been used. */ interface MediaFile : Closeable { + /** The absolute path of the file on disk; see [toFile] to get a [File] instead. */ fun path(): String /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt index 39718ddfd2..660bb0b80e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/MediaPreviewService.kt @@ -10,6 +10,11 @@ package io.element.android.libraries.matrix.api.media import kotlinx.coroutines.flow.StateFlow +/** + * Reads and writes the account settings that decide whether media previews and invite avatars are shown. + * + * These are account data settings shared across clients, so the local value is only updated once the server has accepted the change. + */ interface MediaPreviewService { /** * Will fetch the media preview config from the server. @@ -24,11 +29,15 @@ interface MediaPreviewService { /** * Set the media preview display policy. This will update the value on the server and update the local value when successful. + * + * @param mediaPreviewValue whether media previews are shown always, never, or only in private rooms. */ suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result /** * Set the invite avatars display policy. This will update the value on the server and update the local value when successful. + * + * @param hide true to hide the avatars of users and rooms the user has been invited by. */ suspend fun setHideInviteAvatars(hide: Boolean): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt index 09ceaa4712..c28bfa9933 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.matrix.api.mxc +/** + * Helpers to work with Matrix Content (`mxc://`) URIs. + */ interface MxcTools { /** * Sanitizes an mxcUri to be used as a relative file path. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt index 8a4891a5bf..96ef7044f6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -118,7 +118,7 @@ sealed interface NotificationContent { data class RoomTopic(val topic: String) : StateEvent data object SpaceChild : StateEvent data object SpaceParent : StateEvent - data object BeaconInfo : StateEvent + data class BeaconInfo(val senderId: UserId) : StateEvent } data class Invite( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt index 369587cd10..3a3fbeaa19 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationService.kt @@ -24,6 +24,9 @@ typealias GetNotificationDataResult = Result>): GetNotificationDataResult } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt index 7ae1ca4886..7226de9a9d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notificationsettings/NotificationSettingsService.kt @@ -11,28 +11,114 @@ package io.element.android.libraries.matrix.api.notificationsettings import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationSettings -import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState import kotlinx.coroutines.flow.SharedFlow +/** + * Reads and writes the push rules of the account, which decide what generates a notification. + * + * Rules are account data shared across clients, so a change made here is visible on the user's other devices. + * Several methods take the encryption and one-to-one nature of a room, because Matrix keeps a separate default rule for each combination. + */ interface NotificationSettingsService { /** - * State of the current room notification settings flow ([RoomNotificationSettingsState.Unknown] if not started). + * Emits every time the push rules change, whether from this client or another one, without saying what changed. + * Callers are expected to re-read the values they display; only the most recent signal is buffered. */ val notificationSettingsChangeFlow: SharedFlow + + /** + * Returns the notification settings that apply to one room, which may come from a room specific rule or from the matching default. + * + * @param roomId the room to read the settings of. + * @param isEncrypted whether that room is encrypted, which selects the relevant default rule. + * @param isOneToOne whether that room is a direct message, which selects the relevant default rule. + */ suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result + + /** + * Returns the default notification mode used by rooms that have no rule of their own. + * + * @param isEncrypted whether to read the default for encrypted rooms. + * @param isOneToOne whether to read the default for direct messages. + */ suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result + + /** + * Changes the default notification mode for a whole category of rooms. + * + * @param isEncrypted whether to change the default for encrypted rooms. + * @param mode the new default mode. + * @param isDM whether to change the default for direct messages. + */ suspend fun setDefaultRoomNotificationMode(isEncrypted: Boolean, mode: RoomNotificationMode, isDM: Boolean): Result + + /** + * Sets a room specific notification mode, which from then on overrides the default for that room. + * + * @param roomId the room to change. + * @param mode the new mode for that room. + */ suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result + + /** + * Removes the room specific rule so the room follows the matching default again. + * + * @param roomId the room to reset. + */ suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result + + /** + * Mutes a room, which is a room specific rule like any other and is undone by [unmuteRoom]. + * + * @param roomId the room to mute. + */ suspend fun muteRoom(roomId: RoomId): Result + + /** + * Unmutes a room, restoring either its previous room specific mode or the matching default. + * + * @param roomId the room to unmute. + * @param isEncrypted whether that room is encrypted, which selects the relevant default rule. + * @param isOneToOne whether that room is a direct message, which selects the relevant default rule. + */ suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result + + /** Whether being mentioned through `@room` generates a notification. */ suspend fun isRoomMentionEnabled(): Result + + /** + * Sets whether being mentioned through `@room` generates a notification. + * + * @param enabled true to be notified of `@room` mentions. + */ suspend fun setRoomMentionEnabled(enabled: Boolean): Result + + /** Whether incoming calls generate a notification. */ suspend fun isCallEnabled(): Result + + /** + * Sets whether incoming calls generate a notification. + * + * @param enabled true to be notified of incoming calls. + */ suspend fun setCallEnabled(enabled: Boolean): Result + + /** Whether room invitations generate a notification. */ suspend fun isInviteForMeEnabled(): Result + + /** + * Sets whether room invitations generate a notification. + * + * @param enabled true to be notified of invitations. + */ suspend fun setInviteForMeEnabled(enabled: Boolean): Result + + /** The rooms that have an enabled rule of their own rather than following the defaults. */ suspend fun getRoomsWithUserDefinedRules(): Result> + + /** Whether the homeserver is able to push encrypted events to the device, which decides how much a notification can show before decryption. */ suspend fun canHomeServerPushEncryptedEventsToDevice(): Result + + /** The push rules as raw JSON, for debugging and bug reports; `null` when the server returned none. */ suspend fun getRawPushRules(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt index cc3e2b5cf6..d6a2e71b8f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/MatrixToConverter.kt @@ -20,6 +20,9 @@ interface MatrixToConverter { * - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org * - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org * - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org + * + * @param uri the uri to convert. + * @return the equivalent matrix.to uri, or `null` when the input is not a permalink this converter recognises. */ fun convert(uri: Uri): Uri? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index 878b0e7a69..9140c312c7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -11,8 +11,22 @@ package io.element.android.libraries.matrix.api.permalink import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId +/** + * Builds `matrix.to` permalinks, which are the shareable links to a user or a room; see [PermalinkParser] for the other direction. + */ interface PermalinkBuilder { + /** + * Builds a permalink to a user, failing with [PermalinkBuilderError.InvalidData] when the id is malformed. + * + * @param userId the user to link to. + */ fun permalinkForUser(userId: UserId): Result + + /** + * Builds a permalink to a room from its alias, failing with [PermalinkBuilderError.InvalidData] when the alias is malformed. + * + * @param roomAlias the room alias to link to. + */ fun permalinkForRoomAlias(roomAlias: RoomAlias): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt index 5365b8776a..467b147d98 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkParser.kt @@ -18,7 +18,10 @@ package io.element.android.libraries.matrix.api.permalink interface PermalinkParser { /** * Turns a uri string to a [PermalinkData]. + * A uri that is not a Matrix permalink is reported as a fallback link rather than as an error, so this never throws. * https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md + * + * @param uriString the uri to parse. */ fun parse(uriString: String): PermalinkData } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt index 6764598235..1eafe8ab49 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -8,7 +8,21 @@ package io.element.android.libraries.matrix.api.pusher +/** + * Registers and removes the pusher of this session, i.e. the HTTP gateway the homeserver sends push notifications to. + */ interface PushersService { + /** + * Registers or updates the pusher; calling it again with the same push key replaces the existing registration. + * + * @param setHttpPusherData the gateway URL, the push key and the other details the homeserver needs to reach the device. + */ suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result + + /** + * Removes the pusher, so the homeserver stops sending push notifications to this device. + * + * @param unsetHttpPusherData the push key and app id identifying the registration to remove. + */ suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt index d49343f998..ab65b00f08 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt @@ -66,21 +66,30 @@ interface BaseRoom : Closeable { */ fun isDm() = roomInfoFlow.value.isDm + /** + * The room this one replaced when it was upgraded, or `null` if this room has no predecessor. + * Errors are swallowed and reported as `null`. + */ fun predecessorRoom(): PredecessorRoom? /** * Try to load the room members and update the membersFlow. + * The first call may serve members from the local cache before the server response arrives; later calls always go to the server. */ suspend fun updateMembers() /** * Get the members of the room. Note: generally this should not be used, please use * [membersStateFlow] and [updateMembers] instead. + * + * @param limit the maximum number of members to return. */ suspend fun getMembers(limit: Int = 5): Result> /** * Will return an updated member or an error. + * + * @param userId the member to fetch. */ suspend fun getUpdatedMember(userId: UserId): Result @@ -103,6 +112,8 @@ interface BaseRoom : Closeable { /** * Gets the role of the user with the provided [userId] in the room. + * + * @param userId the member whose role is requested. */ suspend fun userRole(userId: UserId): Result @@ -113,11 +124,15 @@ interface BaseRoom : Closeable { /** * Gets the display name of the user with the provided [userId] in the room. + * + * @param userId the member whose room specific display name is requested. */ suspend fun userDisplayName(userId: UserId): Result /** * Gets the avatar of the user with the provided [userId] in the room. + * + * @param userId the member whose room specific avatar is requested. */ suspend fun userAvatarUrl(userId: UserId): Result @@ -138,6 +153,8 @@ interface BaseRoom : Closeable { /** * Sets the room as favorite or not, based on the [isFavorite] parameter. + * + * @param isFavorite true to mark the room as favorite, false to remove the flag. */ suspend fun setIsFavorite(isFavorite: Boolean): Result @@ -176,22 +193,29 @@ interface BaseRoom : Closeable { suspend fun getRoomVisibility(): Result /** - * Returns the visibility for this room in the room directory, fetching it from the homeserver if needed. + * Returns whether this room is encrypted, based on the latest encryption state known to the SDK rather than on the cached [RoomInfo]. */ suspend fun getUpdatedIsEncrypted(): Result /** * Store the given `ComposerDraft` in the state store of this room. + * + * @param composerDraft the unsent message content to remember. + * @param threadRoot the thread the draft belongs to, or `null` for the main timeline. */ suspend fun saveComposerDraft(composerDraft: ComposerDraft, threadRoot: ThreadId?): Result /** * Retrieve the `ComposerDraft` stored in the state store for this room. + * + * @param threadRoot the thread whose draft is requested, or `null` for the main timeline. */ suspend fun loadComposerDraft(threadRoot: ThreadId?): Result /** * Clear the `ComposerDraft` stored in the state store for this room. + * + * @param threadRoot the thread whose draft should be cleared, or `null` for the main timeline. */ suspend fun clearComposerDraft(threadRoot: ThreadId?): Result @@ -202,10 +226,27 @@ interface BaseRoom : Closeable { */ suspend fun reportRoom(reason: String?): Result + /** + * Declines the incoming call advertised by the given notification, letting the other devices of the room know. + * + * @param notificationEventId the id of the call notification event being declined. + */ suspend fun declineCall(notificationEventId: EventId): Result + /** + * Emits the id of each user who declines the call advertised by the given notification. + * Used to stop ringing once the callee has declined from another device. + * + * @param notificationEventId the id of the call notification event to watch. + */ suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow + /** + * Returns the id of the thread the given event belongs to, fetching the event from the server if it is not cached locally. + * + * @param eventId the event whose thread is requested. + * @return the thread root id, or `null` if the event is not part of a thread. + */ suspend fun threadRootIdForEvent(eventId: EventId): Result /** @@ -213,5 +254,6 @@ interface BaseRoom : Closeable { */ fun destroy() + /** Same as [destroy], so that a room can be used with `use { }`. */ override fun close() = destroy() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index 6d8325427b..98498131f9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -28,11 +28,28 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +/** + * A room the current user has joined, adding to [BaseRoom] everything that requires membership: sending events, moderation and room settings. + * + * An instance owns SDK resources, in particular [liveTimeline], so it must be closed once no longer needed. + */ interface JoinedRoom : BaseRoom { + /** + * A counter incremented every time a synced event is received on [liveTimeline], so callers can refresh data derived from the room. + * It carries no meaning beyond "something changed", and is only updated while it has subscribers. + */ val syncUpdateFlow: StateFlow + /** The members currently typing in the room, never including the current user; starts as an empty list. */ val roomTypingMembersFlow: Flow> + + /** Emits when the verification state of other members' identities changes, for instance after a member resets their identity. */ val identityStateChangesFlow: Flow> + + /** + * The notification settings that apply to this room. + * Starts as [RoomNotificationSettingsState.Unknown]: [updateRoomNotificationSettings] must be called to populate it. + */ val roomNotificationSettingsStateFlow: StateFlow /** @@ -45,6 +62,7 @@ interface JoinedRoom : BaseRoom { */ val liveTimeline: Timeline + /** Gives access to the paginated list of threads started in this room. */ val threadsListService: ThreadsListService /** @@ -55,6 +73,14 @@ interface JoinedRoom : BaseRoom { createTimelineParams: CreateTimelineParams, ): Result + /** + * Replaces the content of an event previously sent by the current user, by sending an edit of it. + * + * @param eventId the event to edit. + * @param body the new content, as plain text. + * @param htmlBody the new content as HTML, or `null` when the message has no formatted version. + * @param intentionalMentions the users and rooms this new content deliberately mentions. + */ suspend fun editMessage(eventId: EventId, body: String, htmlBody: String?, intentionalMentions: List): Result /** @@ -63,12 +89,28 @@ interface JoinedRoom : BaseRoom { */ suspend fun typingNotice(isTyping: Boolean): Result + /** + * Invites a user to this room. + * + * @param id the user to invite. + */ suspend fun inviteUserById(id: UserId): Result + /** + * Uploads [data] as the new avatar of the room, not of the current user. + * + * @param mimeType the MIME type of the image, for instance `image/jpeg`. + * @param data the raw bytes of the image. + */ suspend fun updateAvatar(mimeType: String, data: ByteArray): Result + /** Removes the avatar of the room. */ suspend fun removeAvatar(): Result + /** + * Refreshes [roomNotificationSettingsStateFlow], which moves to a pending state and then to a ready or error state. + * The failure is reported through that flow as well as through the returned [Result]. + */ suspend fun updateRoomNotificationSettings(): Result /** @@ -83,11 +125,15 @@ interface JoinedRoom : BaseRoom { /** * Update the room's visibility in the room directory. + * + * @param roomVisibility whether the room should be listed publicly in the directory. */ suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result /** - * Update room history visibility for this room. + * Update room history visibility for this room, i.e. how much of the past a new member is allowed to read. + * + * @param historyVisibility the new history visibility to apply. */ suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result @@ -98,6 +144,8 @@ interface JoinedRoom : BaseRoom { * - `true` if the room alias didn't exist and it's now published. * - `false` if the room alias was already present so it couldn't be * published. + * + * @param roomAlias the alias to publish. */ suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result @@ -108,6 +156,8 @@ interface JoinedRoom : BaseRoom { * - `true` if the room alias was present and it's now removed from the * room directory. * - `false` if the room alias didn't exist so it couldn't be removed. + * + * @param roomAlias the alias to remove. */ suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result @@ -117,26 +167,74 @@ interface JoinedRoom : BaseRoom { suspend fun enableEncryption(): Result /** - * Update the join rule for this room. + * Update the join rule for this room, i.e. who is allowed to join it. + * + * @param joinRule the new join rule to apply. */ suspend fun updateJoinRule(joinRule: JoinRule): Result + /** + * Changes the power level of individual members, which is how roles such as moderator or administrator are granted. + * + * @param changes the new power level to apply to each user. + */ suspend fun updateUsersRoles(changes: List): Result + /** + * Changes the power level required for each action in the room, such as inviting, kicking or changing the room name. + * + * @param roomPowerLevelsValues the new threshold for every action. + */ suspend fun updatePowerLevels(roomPowerLevelsValues: RoomPowerLevelsValues): Result + /** Restores the power levels required for each action to the defaults the room was created with. */ suspend fun resetPowerLevels(): Result + /** + * Changes the name of the room. + * + * @param name the new room name. + */ suspend fun setName(name: String): Result + /** + * Changes the topic of the room. + * + * @param topic the new room topic. + */ suspend fun setTopic(topic: String): Result + /** + * Reports an event to the homeserver moderators, optionally ignoring its sender at the same time. + * + * @param eventId the event being reported. + * @param reason the explanation sent to the moderators. + * @param blockUserId the sender to also add to the ignore list, or `null` to only report the event. + */ suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result + /** + * Removes a member from the room; they are able to join again unless they are also banned. + * + * @param userId the member to remove. + * @param reason the reason recorded in the membership event, or `null` for none. + */ suspend fun kickUser(userId: UserId, reason: String? = null): Result + /** + * Bans a user from the room, removing them if they are a member and preventing them from joining again. + * + * @param userId the user to ban. + * @param reason the reason recorded in the membership event, or `null` for none. + */ suspend fun banUser(userId: UserId, reason: String? = null): Result + /** + * Lifts the ban on a user, allowing them to join the room again; it does not invite them back. + * + * @param userId the user to unban. + * @param reason the reason recorded in the membership event, or `null` for none. + */ suspend fun unbanUser(userId: UserId, reason: String? = null): Result /** @@ -161,6 +259,12 @@ interface JoinedRoom : BaseRoom { */ fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result + /** + * Enables or disables the send queue of this room only; see [io.element.android.libraries.matrix.api.MatrixClient.setAllSendQueuesEnabled] + * for the session-wide equivalent and for why a queue disables itself. + * + * @param enabled whether the send queue of this room should be enabled. + */ suspend fun setSendQueueEnabled(enabled: Boolean) /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt index fd5e71ff9d..a47179cae7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/NotJoinedRoom.kt @@ -12,7 +12,13 @@ import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo /** A reference to a room either invited, knocked or banned. */ interface NotJoinedRoom : AutoCloseable { + /** What is publicly known about the room: name, topic, avatar, join rule and member count. */ val previewInfo: RoomPreviewInfo + + /** + * The local room, available when the user has a membership in it, for instance after being invited. + * It is `null` for a room only discovered through a preview, and closing this instance also closes it. + */ val localRoom: BaseRoom? /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 1874986d70..9155d47a08 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser( userId = userId, displayName = displayName.takeUnless { membership == RoomMembershipState.BAN }, avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN }, + displayedStatus = displayedStatus, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt index c23252961f..aecd2ab7f4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/RoomAliasHelper.kt @@ -10,7 +10,21 @@ package io.element.android.libraries.matrix.api.room.alias import io.element.android.libraries.matrix.api.core.RoomAlias +/** + * Helpers to build and validate room aliases, delegating to the SDK so that the app and the server agree on what is acceptable. + */ interface RoomAliasHelper { + /** + * Turns a room display name into a usable alias local part, dropping the characters that are not allowed in an alias. + * + * @param name the room display name to derive the alias from. + */ fun roomAliasNameFromRoomDisplayName(name: String): String + + /** + * Whether the given alias is well-formed; this is a format check only and says nothing about the alias being taken. + * + * @param roomAlias the alias to validate. + */ fun isRoomAliasValid(roomAlias: RoomAlias): Boolean } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt index 4220b68f0d..a6b1f3d36b 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt @@ -11,7 +11,18 @@ package io.element.android.libraries.matrix.api.room.join import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +/** + * Joins a room and reports the join to analytics, so that callers do not have to do both. + */ interface JoinRoom { + /** + * Joins the room, picking the right SDK call depending on whether an id or an alias was given, and captures an analytics event on success. + * A `Forbidden` answer from the server is translated into [Failures.UnauthorizedJoin] so callers can tell it apart from other errors. + * + * @param roomIdOrAlias the id or the alias of the room to join. + * @param serverNames servers to ask about the room, ignored when joining by alias. + * @param trigger what made the user join, recorded in the analytics event. + */ suspend operator fun invoke( roomIdOrAlias: RoomIdOrAlias, serverNames: List, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt index 317157793e..386be0d5c9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/knock/KnockRequest.kt @@ -11,20 +11,50 @@ package io.element.android.libraries.matrix.api.room.knock import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +/** + * A pending request from a user to join a room whose join rule is knock, along with the actions a moderator can take on it. + * + * Instances are snapshots handed out by [io.element.android.libraries.matrix.api.room.JoinedRoom.knockRequestsFlow]. + */ interface KnockRequest { + /** The id of the membership event carrying the request. */ val eventId: EventId + + /** The user asking to join. */ val userId: UserId + + /** The display name of the user asking to join, or `null` if they have none. */ val displayName: String? + + /** The avatar of the user asking to join, or `null` if they have none. */ val avatarUrl: String? + + /** The message the user attached to their request, or `null` if they sent none. */ val reason: String? + + /** When the request was made, in milliseconds since the epoch, or `null` if the server did not provide it. */ val timestamp: Long? + + /** Whether a moderator has already marked this request as seen, so that it is no longer highlighted as new. */ val isSeen: Boolean + /** Accepts the request, which makes the user a member of the room. */ suspend fun accept(): Result + /** + * Declines the request; the user is free to knock again. + * + * @param reason the explanation recorded in the membership event, or `null` for none. + */ suspend fun decline(reason: String?): Result + /** + * Declines the request and bans the user, so that they cannot knock again. + * + * @param reason the explanation recorded in the membership event, or `null` for none. + */ suspend fun declineAndBan(reason: String?): Result + /** Marks the request as seen, without accepting or declining it. */ suspend fun markAsSeen(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt index a12f7d9606..19c01b9975 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt @@ -59,12 +59,16 @@ interface RoomPermissions : AutoCloseable { /** * Returns true if the current user is able to send a specific message type * in the room. + * + * @param message the kind of message the caller wants to send. */ fun canOwnUserSendMessage(message: MessageEventType): Boolean /** * Returns true if the current user is able to send a specific state event * type in the room. + * + * @param stateEvent the kind of state event the caller wants to send. */ fun canOwnUserSendState(stateEvent: StateEventType): Boolean @@ -77,48 +81,66 @@ interface RoomPermissions : AutoCloseable { /** * Returns true if the user with the given userId is able to ban in the * room. + * + * @param userId the member whose ability is checked. */ fun canUserBan(userId: UserId): Boolean /** * Returns true if the user with the given userId is able to invite in the * room. + * + * @param userId the member whose ability is checked. */ fun canUserInvite(userId: UserId): Boolean /** * Returns true if the user with the given userId is able to kick in the * room. + * + * @param userId the member whose ability is checked. */ fun canUserKick(userId: UserId): Boolean /** * Returns true if the user with the given userId is able to pin or unpin * events in the room. + * + * @param userId the member whose ability is checked. */ fun canUserPinUnpin(userId: UserId): Boolean /** * Returns true if the user with the given userId is able to redact * messages of other users in the room. + * + * @param userId the member whose ability is checked. */ fun canUserRedactOther(userId: UserId): Boolean /** * Returns true if the user with the given userId is able to redact * their own messages in the room. + * + * @param userId the member whose ability is checked. */ fun canUserRedactOwn(userId: UserId): Boolean /** * Returns true if the user with the given userId is able to send a * specific message type in the room. + * + * @param userId the member whose ability is checked. + * @param message the kind of message to check. */ fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean /** * Returns true if the user with the given userId is able to send a * specific state event type in the room. + * + * @param userId the member whose ability is checked. + * @param stateEvent the kind of state event to check. */ fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean @@ -127,6 +149,8 @@ interface RoomPermissions : AutoCloseable { * notification in the room. * * The call may fail if there is an error in getting the power levels. + * + * @param userId the member whose ability is checked. */ fun canUserTriggerRoomNotification(userId: UserId): Boolean } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt index 7f819c540c..f544211741 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/threads/ThreadsListService.kt @@ -9,10 +9,27 @@ package io.element.android.libraries.matrix.api.room.threads import kotlinx.coroutines.flow.Flow +/** + * Exposes the paginated list of threads of a single room, kept up to date by the SDK. + * + * The list grows through explicit [paginate] calls; [destroy] must be called to release the underlying SDK resources. + */ interface ThreadsListService { + /** + * The threads loaded so far, starting empty and re-emitted in full whenever the SDK sends an update. + * The underlying subscription is started on the first call and shared by later ones. + */ fun subscribeToItemUpdates(): Flow> + + /** The pagination state of the list, emitting the current value immediately so callers know whether more threads can be loaded. */ fun subscribeToPaginationUpdates(): Flow + + /** Loads the next page of threads, which will be reflected in [subscribeToItemUpdates]. */ suspend fun paginate(): Result + + /** Discards the loaded threads and starts the list again from the most recent one. */ suspend fun reset(): Result + + /** Releases the SDK resources and stops the item subscription; the service is unusable afterwards. */ fun destroy() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt index c48bf54e9c..49de1bee89 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryList.kt @@ -10,12 +10,21 @@ package io.element.android.libraries.matrix.api.roomdirectory import kotlinx.coroutines.flow.Flow +/** + * A single, stateful search in the public room directory: one query at a time, with paginated results. + * + * Obtain an instance from [RoomDirectoryService.createRoomDirectoryList]. + */ interface RoomDirectoryList { /** * Starts a filtered search for the server. * If the filter is not provided it will search for all the rooms. You can specify a batch_size to control the number of rooms to fetch per request. * If the via_server is not provided it will search in the current homeserver by default. * This method will clear the current search results and start a new one + * + * @param filter the text to look for, or `null` to list every room. + * @param batchSize the number of rooms to fetch per request. + * @param viaServerName the server whose directory to search, or `null` to use the user's own homeserver. */ suspend fun filter(filter: String?, batchSize: Int, viaServerName: String?): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt index 8f71cfb18c..65f83299cb 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomdirectory/RoomDirectoryService.kt @@ -10,6 +10,14 @@ package io.element.android.libraries.matrix.api.roomdirectory import kotlinx.coroutines.CoroutineScope +/** + * Gives access to the public room directory, i.e. the rooms a server advertises to users who have not joined them. + */ interface RoomDirectoryService { + /** + * Creates an independent directory search, which is stateful and holds one query at a time. + * + * @param scope the lifetime of the search; the underlying SDK resources are released when it completes, so there is nothing to close by hand. + */ fun createRoomDirectoryList(scope: CoroutineScope): RoomDirectoryList } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt index 952cee6f22..eee98b82e9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/DynamicRoomList.kt @@ -14,6 +14,7 @@ package io.element.android.libraries.matrix.api.roomlist * It lets load rooms on demand and filter them. */ interface DynamicRoomList : RoomList { + /** The number of rooms added to the list by each [loadMore] call, as chosen when the list was created. */ val pageSize: Int /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt index 600866f586..2c11c750e4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt @@ -7,12 +7,25 @@ package io.element.android.libraries.matrix.api.scanner +import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.core.SessionId + /** * Provides the URL of the content scanner service for a given homeserver, if any is set up. */ fun interface ContentScannerUrlProvider { /** - * Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up. + * Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up. */ - suspend fun getContentScannerUrl(homeserver: String): Result + suspend fun getContentScannerUrl(sessionId: SessionId): Result + + /** + * Creates a provider, which needs a fetcher because the scanner URL is itself discovered over HTTP. + */ + fun interface Factory { + /** + * @param urlContentFetcher used to read the configuration advertised by the homeserver; a client that is not authenticated yet also works. + */ + fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider + } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/MessageSearchService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/MessageSearchService.kt index 3e20b163f2..d7d8c017e3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/MessageSearchService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/MessageSearchService.kt @@ -10,6 +10,11 @@ package io.element.android.libraries.matrix.api.search import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.CoroutineScope +/** + * Creates the message search cursors backed by the local search index. + * + * Only usable when the session was built with the index attached, see [io.element.android.libraries.matrix.api.MatrixClient.isMessageSearchAvailable]. + */ interface MessageSearchService { /** * Create a new, independent search cursor. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt index 3529b17d85..6c3c16b35e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/server/UserServerResolver.kt @@ -8,6 +8,10 @@ package io.element.android.libraries.matrix.api.server +/** + * Resolves the server name of the logged in user, which is the part of their user id after the colon. + */ interface UserServerResolver { + /** Returns the server name, for instance `matrix.org` for `@alice:matrix.org`. */ fun resolve(): String } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt index 174ec20dc9..59b9657b58 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/LeaveSpaceHandle.kt @@ -10,6 +10,11 @@ package io.element.android.libraries.matrix.api.spaces import io.element.android.libraries.matrix.api.core.RoomId +/** + * Handles leaving a space, letting the user also choose which of its rooms to leave in the same operation. + * + * Obtain an instance from [SpaceService.getLeaveSpaceHandle]; the caller owns it and must [close] it. + */ interface LeaveSpaceHandle { /** * The id of the space to leave. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt index 2ccee8d1ee..b29d721d07 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceRoomList.kt @@ -16,22 +16,36 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import java.util.Optional +/** + * The paginated list of the children of a single space, kept up to date by the SDK. + * + * Obtain an instance from [SpaceService.spaceRoomList]; the caller owns it and must call [destroy] when done. + */ interface SpaceRoomList { sealed interface PaginationStatus { data object Loading : PaginationStatus data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus } + /** The space whose children this list holds. */ val spaceId: RoomId + /** What is known about the space itself, starting empty until the SDK provides it. */ val currentSpaceFlow: StateFlow> + /** The children loaded so far, re-emitted in full on every update; the latest value is replayed to new collectors. */ val spaceRoomsFlow: Flow> + + /** Whether a page is being loaded and whether more children remain; see [loadAllIncrementally] to drain the list. */ val paginationStatusFlow: StateFlow + /** Loads the next page of children, which will be reflected in [spaceRoomsFlow]. */ suspend fun paginate(): Result + + /** Discards the loaded children and starts the list again from the beginning. */ suspend fun reset(): Result + /** Releases the SDK resources and cancels the internal scope; the list is unusable afterwards. */ fun destroy() } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 299209e188..a7126a4766 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -11,13 +11,35 @@ package io.element.android.libraries.matrix.api.spaces import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.coroutines.flow.SharedFlow +/** + * Gives access to the spaces the user belongs to, their hierarchy, and the operations that change it. + */ interface SpaceService { + /** The spaces that have no joined parent, i.e. the roots of the hierarchy shown to the user; the latest value is replayed to new collectors. */ val topLevelSpacesFlow: SharedFlow> + + /** The filters the space list can be narrowed down with; as with [topLevelSpacesFlow], the latest value is replayed. */ val spaceFiltersFlow: SharedFlow> + + /** + * Returns the spaces the given room or space is a child of, restricted to the ones the user has joined. + * + * @param spaceId the room or space whose parents are requested. + */ suspend fun joinedParents(spaceId: RoomId): Result> + /** + * Returns what is known about a single space, or `null` if it is unknown or the lookup failed. + * + * @param spaceId the space to look up. + */ suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? + /** + * Creates a paginated list of the children of a space; the caller owns it and must call `destroy` on it. + * + * @param id the space whose children are listed. + */ fun spaceRoomList(id: RoomId): SpaceRoomList /** @@ -25,6 +47,11 @@ interface SpaceService { */ suspend fun editableSpaces(): Result> + /** + * Creates a handle to leave a space, which lets the user also pick which of its rooms to leave; the caller must close it. + * + * @param spaceId the space the user wants to leave. + */ fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt index ce0019d8dc..92f60352d7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -10,21 +10,31 @@ package io.element.android.libraries.matrix.api.sync import kotlinx.coroutines.flow.StateFlow +/** + * Drives the sync loop of a single session and exposes its current state. + * + * Both [startSync] and [stopSync] are idempotent and never throw: any error is wrapped in the returned [Result]. + */ interface SyncService { /** - * Tries to start the sync. If already syncing it has no effect. + * Tries to start the sync. If already syncing, or if the service has been destroyed, it has no effect. */ suspend fun startSync(): Result /** - * Tries to stop the sync. If service is not syncing it has no effect. + * Tries to stop the sync. If the service is not syncing, or has been destroyed, it has no effect. */ suspend fun stopSync(): Result /** * Flow of [SyncState]. Will be updated as soon as the current [SyncState] changes. + * Starts at [SyncState.Idle] and only emits on an actual change, for as long as the session lives. */ val syncState: StateFlow + /** + * Whether the client currently considers itself online, derived from [syncState]. + * This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online. + */ val isOnline: StateFlow } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 8cb933205b..72483e68f1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -31,6 +31,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize import java.io.File +/** + * A window over the events of a room, restricted by its [Mode], and the entry point for sending events to that room. + * + * A timeline owns SDK resources and must be closed once no longer needed, except the live timeline, which is owned by its room. + * Sending returns successfully as soon as the event has been handed to the room's send queue, not when the server has accepted it. + */ interface Timeline : AutoCloseable { data class PaginationStatus( val isPaginating: Boolean, @@ -54,18 +60,66 @@ interface Timeline : AutoCloseable { data class Thread(val threadRootId: ThreadId) : Mode } + /** What this timeline is a view over: the live room, a single event and its context, the pinned events, the media, or a thread. */ val mode: Mode + + /** + * Emits every time a membership change event is received in this timeline, without saying which one. + * Collecting it keeps the timeline subscribed to the SDK, so it should not be collected for longer than needed. + */ val membershipChangeEventReceived: Flow + + /** + * Emits every time an event coming from sync is received in this timeline, without saying which one. + * As with [membershipChangeEventReceived], collecting it keeps the timeline subscribed to the SDK. + */ val onSyncedEventReceived: Flow + + /** + * Sends a read receipt for a single event, which is how the unread state of the room is cleared. + * + * @param eventId the event the receipt points at. + * @param receiptType whether the receipt is public, private or only marks the fully read position. + */ suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result + + /** + * Marks the whole timeline as read by sending a receipt on its latest event. + * + * @param receiptType whether the receipt is public, private or only marks the fully read position. + */ suspend fun markAsRead(receiptType: ReceiptType): Result + + /** + * Loads one more page of events and updates the matching pagination status flow. + * Fails with a `CannotPaginate` error when the corresponding status does not allow it, for instance because a pagination is already running. + * + * @param direction whether to load older or newer events. + * @return whether the end of the timeline has been reached in that direction. + */ suspend fun paginate(direction: PaginationDirection): Result + /** Whether older events are being loaded and whether more remain; pinned event timelines start with nothing more to load. */ val backwardPaginationStatus: StateFlow + + /** Whether newer events are being loaded and whether more remain; only a timeline focused on an event starts with more to load. */ val forwardPaginationStatus: StateFlow + /** + * The items to display, in timeline order, re-emitted on every change. + * Beyond the room events this also carries the virtual items the UI needs, such as date separators, loading indicators and the room beginning. + */ val timelineItems: Flow> + /** + * Sends a text message to the room this timeline belongs to. + * + * @param body the message content, as plain text. + * @param htmlBody the message content as HTML, or `null` to send it unformatted. + * @param intentionalMentions the users and rooms the message deliberately mentions, so that they are notified. + * @param msgType the Matrix message type to use, which distinguishes a normal message from an emote or a notice. + * @param asPlainText whether to send the message without any formatting, ignoring markdown in [body]. + */ suspend fun sendMessage( body: String, htmlBody: String?, @@ -74,6 +128,14 @@ interface Timeline : AutoCloseable { asPlainText: Boolean = false, ): Result + /** + * Replaces the content of a message, whether it has already been sent or is still local in the send queue. + * + * @param eventOrTransactionId the event to edit, identified by its event id once sent or by its transaction id while still local. + * @param body the new content, as plain text. + * @param htmlBody the new content as HTML, or `null` to send it unformatted. + * @param intentionalMentions the users and rooms the new content deliberately mentions. + */ suspend fun editMessage( eventOrTransactionId: EventOrTransactionId, body: String, @@ -81,12 +143,29 @@ interface Timeline : AutoCloseable { intentionalMentions: List, ): Result + /** + * Replaces the caption of an already sent media message, leaving the media itself untouched. + * + * @param eventOrTransactionId the media event to edit, identified by its event id or by its transaction id while still local. + * @param caption the new caption, or `null` to remove it. + * @param formattedCaption the new caption as HTML, or `null` to send it unformatted. + */ suspend fun editCaption( eventOrTransactionId: EventOrTransactionId, caption: String?, formattedCaption: String?, ): Result + /** + * Sends a message as a reply to another event. + * + * @param repliedToEventId the event being replied to. + * @param body the reply content, as plain text. + * @param htmlBody the reply content as HTML, or `null` to send it unformatted. + * @param intentionalMentions the users and rooms the reply deliberately mentions. + * @param fromNotification whether the reply was written from a notification rather than from the timeline. + * @param msgType the Matrix message type to use. + */ suspend fun replyMessage( repliedToEventId: EventId, body: String, @@ -96,6 +175,16 @@ interface Timeline : AutoCloseable { msgType: MsgType = MsgType.MSG_TYPE_TEXT, ): Result + /** + * Starts sending an image. The returned [MediaUploadHandler] tracks the upload and must be awaited by the caller to know the outcome. + * + * @param file the image to upload. + * @param thumbnailFile a pre-generated thumbnail, or `null` to send none. + * @param imageInfo the dimensions, size and MIME type of the image. + * @param caption the text shown along with the image, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendImage( file: File, thumbnailFile: File?, @@ -105,6 +194,16 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending a video, with the same upload contract as [sendImage]. + * + * @param file the video to upload. + * @param thumbnailFile a pre-generated thumbnail, or `null` to send none. + * @param videoInfo the duration, dimensions, size and MIME type of the video. + * @param caption the text shown along with the video, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendVideo( file: File, thumbnailFile: File?, @@ -114,6 +213,15 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending an audio file, with the same upload contract as [sendImage]; use [sendVoiceMessage] for a recorded voice message. + * + * @param file the audio file to upload. + * @param audioInfo the duration, size and MIME type of the audio. + * @param caption the text shown along with the audio, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendAudio( file: File, audioInfo: AudioInfo, @@ -122,6 +230,15 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending an arbitrary file, with the same upload contract as [sendImage]. + * + * @param file the file to upload. + * @param fileInfo the size and MIME type of the file. + * @param caption the text shown along with the file, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendFile( file: File, fileInfo: FileInfo, @@ -151,6 +268,14 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending a recorded voice message, with the same upload contract as [sendImage]. + * + * @param file the recorded audio to upload. + * @param audioInfo the duration, size and MIME type of the recording. + * @param waveform the amplitudes used to draw the waveform, normalised between 0 and 1. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendVoiceMessage( file: File, audioInfo: AudioInfo, @@ -158,6 +283,14 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending several media items as a single gallery message, with the same upload contract as [sendImage]. + * + * @param items the media making up the gallery, in the order they should appear. + * @param caption the text shown along with the gallery, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendGallery( items: List, caption: String?, @@ -165,12 +298,36 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Redacts an event, i.e. asks the server to remove its content; it also removes a local echo that has not been sent yet. + * + * @param eventOrTransactionId the event to redact, identified by its event id once sent or by its transaction id while still local. + * @param reason the explanation recorded in the redaction event, or `null` for none. + */ suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result + /** + * Adds the given reaction to an event, or removes it if the current user had already reacted with it. + * + * @param emoji the reaction key, usually an emoji. + * @param eventOrTransactionId the event to react to, identified by its event id or by its transaction id while still local. + * @return whether the reaction is now present. + */ suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result + /** + * Forwards the content of an event to other rooms, as new messages sent by the current user. + * + * @param eventId the event whose content is forwarded. + * @param roomIds the rooms to forward it to. + */ suspend fun forwardEvent(eventId: EventId, roomIds: List): Result + /** + * Cancels an event that is still waiting in the send queue, by redacting its local echo. + * + * @param transactionId the transaction id of the local echo to drop. + */ suspend fun cancelSend(transactionId: TransactionId): Result = redactEvent(transactionId.toEventOrTransactionId(), reason = null) @@ -222,12 +379,20 @@ interface Timeline : AutoCloseable { */ suspend fun endPoll(pollStartId: EventId, text: String): Result + /** + * Resolves the content of a replied-to event so it can be rendered above the reply. + * Served from the loaded timeline items when possible, otherwise fetched through the SDK. + * + * @param eventId the event being replied to. + */ suspend fun loadReplyDetails(eventId: EventId): InReplyTo /** * Returns true if [eventId] is currently loaded in this timeline's window, even if the display * layer does not render it (e.g. filtered or state events). This distinguishes "in the window * but not displayed" from "genuinely outside the loaded window". + * + * @param eventId the event to look for. */ suspend fun isEventLoaded(eventId: EventId): Boolean @@ -237,6 +402,8 @@ interface Timeline : AutoCloseable { * * Returns `true` if we sent the request, `false` if the event was already * pinned. + * + * @param eventId the event to pin. */ suspend fun pinEvent(eventId: EventId): Result @@ -246,11 +413,13 @@ interface Timeline : AutoCloseable { * * Returns `true` if we sent the request, `false` if the event wasn't * pinned + * + * @param eventId the event to unpin. */ suspend fun unpinEvent(eventId: EventId): Result /** - * Get the latest event id of the timeline. + * Get the latest event id of the timeline, or `null` when it holds no event yet. */ suspend fun getLatestEventId(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt index 28e487f527..ef9d5d794e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.first * By default, the active timeline is the live timeline. */ fun interface TimelineProvider { + /** The timeline currently in use, or `null` while none is available yet; see [getActiveTimeline] to await a non-null value. */ fun activeTimelineFlow(): StateFlow } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index f3982e83d2..91703f7fa4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -60,14 +60,30 @@ data class EventTimelineItem( } } +/** + * Provides the debug information of a timeline item on demand, so the original event JSON is only fetched from the SDK when actually displayed. + */ fun interface TimelineItemDebugInfoProvider { + /** Returns the debug information of the item, including its original and latest edit JSON. */ operator fun invoke(): TimelineItemDebugInfo } +/** + * Provides the encryption warning to show next to a timeline item on demand, so the check is only run for items that are displayed. + */ fun interface MessageShieldProvider { + /** + * Returns the warning to display for this event, or `null` when there is nothing to warn about. + * + * @param strict whether to also report the weaker problems that are hidden by default. + */ operator fun invoke(strict: Boolean): MessageShield? } +/** + * Provides the send queue handle of a timeline item on demand, which is needed to retry or cancel a failed send. + */ fun interface SendHandleProvider { + /** Returns the send handle of the item, or `null` when it is not a local echo waiting in the send queue. */ operator fun invoke(): SendHandle? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileDetails.kt index 73b0478d63..1a56e888fa 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileDetails.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/ProfileDetails.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event import androidx.compose.runtime.Immutable import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.MatrixUser @Immutable sealed interface ProfileDetails { @@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? { else -> null } } + +fun ProfileDetails.toMatrixUser(userId: UserId): MatrixUser { + return when (this) { + is ProfileDetails.Ready -> MatrixUser( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + displayedStatus = displayedStatus, + ) + else -> MatrixUser( + userId = userId, + displayName = null, + avatarUrl = null, + displayedStatus = null, + ) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt index d4ef27fd0d..f5de427ce0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingService.kt @@ -10,8 +10,21 @@ package io.element.android.libraries.matrix.api.tracing import timber.log.Timber +/** + * Routes the app's own logs into the SDK's tracing subsystem, so that Kotlin and Rust logs end up interleaved in the same files. + */ interface TracingService { + /** + * Creates a Timber tree that forwards what the app logs to the SDK tracing subsystem. + * + * @param target the tracing target name the logs are attributed to, used to tell subsystems apart in the output. + */ fun createTimberTree(target: String): Timber.Tree + /** + * Turns writing logs to files on or off at runtime, which is how the user-facing logging toggle is applied. + * + * @param config where to write the log files, or the disabled configuration to stop writing them. + */ fun updateWriteToFilesConfiguration(config: WriteToFilesConfiguration) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt index 581658ff28..8e4ced7202 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/DisplayedStatus.kt @@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize @Immutable sealed interface DisplayedStatus : Parcelable { + /** Useful for adding extension methods. */ + companion object + /** Status set manually by the user via m.status. */ @Parcelize data class UserSet(val status: UserStatus) : DisplayedStatus @@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable { @Parcelize data class InCall( /** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */ - val callJoinedTs: Long, + val callJoinedTs: Long?, ) : DisplayedStatus } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt index 38a00d833d..de9b11e882 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -13,6 +13,12 @@ import io.element.android.libraries.matrix.api.core.UserId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +/** + * Drives session verification: proving to the account that this device is trusted, and verifying other users. + * + * A verification is a stateful exchange between two devices, so at most one flow is active at a time and its progress is reported + * through [verificationFlowState] rather than through the return value of each call. + */ interface SessionVerificationService { /** * State of the current verification flow ([VerificationFlowState.Initial] if not started). @@ -37,6 +43,8 @@ interface SessionVerificationService { /** * Request verification of the user with the given [userId]. + * + * @param userId the other user to verify. */ suspend fun requestUserVerification(userId: UserId) @@ -62,17 +70,23 @@ interface SessionVerificationService { /** * Returns the verification service state to the initial step. + * + * @param cancelAnyPendingVerificationAttempt whether to also cancel a verification that is still in progress. */ suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) /** * Register a listener to be notified of incoming session verification requests. + * + * @param listener the listener to notify, or `null` to stop being notified. */ fun setListener(listener: SessionVerificationServiceListener?) /** * Set this particular request as the currently active one and register for * events pertaining it. + * + * @param verificationRequest the incoming request to make active. */ suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) @@ -82,7 +96,15 @@ interface SessionVerificationService { suspend fun acceptVerificationRequest() } +/** + * Notified when another device asks to verify this session; register one through [SessionVerificationService.setListener]. + */ interface SessionVerificationServiceListener { + /** + * Called when a verification request is received, so the app can prompt the user about it. + * + * @param verificationRequest the incoming request, to be passed to [SessionVerificationService.acknowledgeVerificationRequest] to act on it. + */ fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt index 5b2a691afc..1b45c4f1a0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallAnalyticCredentialsProvider.kt @@ -8,10 +8,24 @@ package io.element.android.libraries.matrix.api.widget +/** + * Supplies the analytics and crash reporting credentials passed to the embedded Element Call widget. + * + * Every member is `null` when the corresponding service is not configured for this build, or when the user has not consented to analytics. + */ interface CallAnalyticCredentialsProvider { + /** The PostHog identifier of the current user, so call analytics can be tied to the same person as app analytics. */ val posthogUserId: String? + + /** The PostHog host the widget should send its events to. */ val posthogApiHost: String? + + /** The PostHog API key the widget should use. */ val posthogApiKey: String? + + /** The URL the widget should submit its rageshakes to. */ val rageshakeSubmitUrl: String? + + /** The Sentry DSN the widget should report its crashes to. */ val sentryDsn: String? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt index d0686e8e5f..92510c4b8f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/CallWidgetSettingsProvider.kt @@ -10,7 +10,18 @@ package io.element.android.libraries.matrix.api.widget import java.util.UUID +/** + * Builds the widget settings used to embed Element Call in a room, applying the app's own preferences on top of the SDK defaults. + */ interface CallWidgetSettingsProvider { + /** + * @param baseUrl the URL Element Call is served from. + * @param widgetId a unique identifier for this widget instance. + * @param encrypted whether the room the call takes place in is encrypted. + * @param direct whether the call happens in a direct message room. + * @param isAudioCall whether the call should start without video. + * @param hasActiveCall whether a call is already ongoing in the room, which affects whether the user joins straight away. + */ suspend fun provide( baseUrl: String, widgetId: String = UUID.randomUUID().toString(), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt index d8cca95842..3b863279cf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/widget/MatrixWidgetDriver.kt @@ -10,10 +10,25 @@ package io.element.android.libraries.matrix.api.widget import kotlinx.coroutines.flow.Flow +/** + * Bridges a widget running in a WebView with the Matrix room it is embedded in, relaying the widget API messages in both directions. + * + * The driver owns SDK resources and must be closed once the widget goes away; Element Call is the main user of this. + */ interface MatrixWidgetDriver : AutoCloseable { + /** The id of the widget this driver serves, matching the one in its settings. */ val id: String + + /** The messages coming from the room that should be forwarded to the widget. */ val incomingMessages: Flow + /** Starts relaying messages and suspends for as long as the driver runs; calling it again while it is running does nothing. */ suspend fun run() + + /** + * Forwards a message from the widget to the room. Does nothing once the driver has been closed. + * + * @param message the raw widget API message emitted by the WebView. + */ suspend fun send(message: String) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 88b1f05909..0ff563a204 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncState -import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.UserStatus @@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser -import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService import io.element.android.libraries.matrix.impl.notification.RustNotificationService @@ -85,7 +83,9 @@ import io.element.android.libraries.matrix.impl.search.RustMessageSearchService import io.element.android.libraries.matrix.impl.spaces.RustSpaceService import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.map -import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper +import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper +import io.element.android.libraries.matrix.impl.user.into +import io.element.android.libraries.matrix.impl.user.map import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService @@ -126,9 +126,11 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.PowerLevels +import org.matrix.rustcomponents.sdk.ProfileListener import org.matrix.rustcomponents.sdk.RoomInfoListener import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener import org.matrix.rustcomponents.sdk.TaskHandle +import org.matrix.rustcomponents.sdk.UserProfile import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File @@ -271,8 +273,6 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) - @Volatile private var localUserStatus: UserStatus? = null - private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -283,6 +283,14 @@ class RustMatrixClient( override val userProfile: StateFlow = _userProfile + private var ownProfileTaskHandle: TaskHandle? = null + + private val ownProfileListener = object : ProfileListener { + override fun onUpdate(profile: UserProfile) { + _userProfile.tryEmit(profile.map()) + } + } + override val ignoredUsersFlow = mxCallbackFlow> { // Fetch the initial value manually, the SDK won't return it automatically channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList()) @@ -303,8 +311,21 @@ class RustMatrixClient( sessionCoroutineScope.launch { // Start notification settings notificationSettingsService.start() + // Setup user profile logic + setupUserProfile() + } - // Update the user profile in the session store if needed + // Schedule regular database vacuuming to ensure DB performance remains optimal + scheduleDatabaseVacuum() + } + + private suspend fun setupUserProfile() { + val supported = isUserStatusSupported().getOrDefault(false) + if (supported) { + // No need to seed the data here, it's already stored by the sdk. + ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener) + } else { + // Seed from the session store so cold-start UI has something to render before the network fetch. sessionStore.getSession(sessionId.value)?.let { sessionData -> _userProfile.emit( MatrixUser( @@ -314,12 +335,8 @@ class RustMatrixClient( ) ) } - // Force a refresh of the profile getUserProfile() } - - // Schedule regular database vacuuming to ensure DB performance remains optimal - scheduleDatabaseVacuum() } override fun userIdServerName(): String { @@ -458,13 +475,8 @@ class RustMatrixClient( override suspend fun getUserProfile(): Result = getProfile(sessionId) .onSuccess { matrixUser -> - _userProfile.emit( - matrixUser.copy( - rawStatus = localUserStatus, - displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) }, - ) - ) // Also update our session storage + _userProfile.emit(matrixUser) sessionStore.updateUserProfile( sessionId = sessionId.value, displayName = matrixUser.displayName, @@ -495,25 +507,31 @@ class RustMatrixClient( } override suspend fun setUserStatus(status: UserStatus): Result = withContext(sessionDispatcher) { - localUserStatus = status - _userProfile.emit( - _userProfile.value.copy( - rawStatus = status, - displayedStatus = DisplayedStatus.UserSet(status), - ) - ) - Result.success(Unit) + runCatchingExceptions { + innerClient.setUserStatus(status.into()) + }.onSuccess { + // The subscription pushes the update on supported servers; only refresh manually as a fallback. + if (ownProfileTaskHandle == null) getUserProfile() + } } override suspend fun clearUserStatus(): Result = withContext(sessionDispatcher) { - localUserStatus = null - _userProfile.emit( - _userProfile.value.copy( - rawStatus = null, - displayedStatus = null, - ) - ) - Result.success(Unit) + runCatchingExceptions { + innerClient.clearUserStatus() + }.onSuccess { + // The subscription pushes the update on supported servers; only refresh manually as a fallback. + if (ownProfileTaskHandle == null) getUserProfile() + } + } + + override suspend fun isUserStatusSupported(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isUserStatusSupported() + } + } + + override fun enableAutomaticCallStatus(enabled: Boolean) { + innerClient.enableAutomaticCallStatus(enabled) } override suspend fun joinRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { @@ -621,6 +639,7 @@ class RustMatrixClient( sessionCoroutineScope.cancel() clientDelegateTaskHandle?.cancelAndDestroy() + ownProfileTaskHandle?.cancelAndDestroy() sessionVerificationService.destroy() sessionDelegate.clearCurrentClient() @@ -806,6 +825,18 @@ class RustMatrixClient( } } + override suspend fun getAccountData(eventType: String): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.accountData(eventType) + } + } + + override suspend fun setAccountData(eventType: String, content: String): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.setAccountData(eventType, content) + } + } + override suspend fun canLinkNewDevice(): Result = withContext(sessionDispatcher) { runCatchingExceptions { innerClient.isLoginWithQrCodeSupported() @@ -855,12 +886,6 @@ class RustMatrixClient( } } - override suspend fun getMapStyleUrl(): Result = withContext(sessionDispatcher) { - runCatchingExceptions { - innerClient.tileServer()?.mapStyleUrl - } - } - override suspend fun resetWellKnownConfig(): Result { return runCatchingExceptions { Timber.d("Resetting well-known config for session $sessionId") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index de2c5c0141..a0b06a2e1e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -17,7 +17,7 @@ import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider import io.element.android.libraries.matrix.impl.analytics.UtdTracker @@ -70,7 +70,7 @@ class RustMatrixClientFactory( private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, private val workManagerScheduler: WorkManagerScheduler, - private val contentScannerUrlProvider: ContentScannerUrlProvider, + private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory, ) { private val sessionDelegate = RustClientSessionDelegate( sessionStore = sessionStore, @@ -135,26 +135,25 @@ class RustMatrixClientFactory( client.setUtdDelegate(UtdTracker(analyticsService)) - val domainName = UserId(client.userId()).domainName // If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client. // This allows the SDK to use the content scanner for automatic media scanning. // If no content scanner URL is available, the contentScanner will be null. - val contentScanner = domainName?.let { - contentScannerUrlProvider.getContentScannerUrl(domainName) - .getOrNull() - ?.let { contentScannerUrl -> - val contentScanner = ContentScanner(contentScannerUrl) - client.setContentScanner(contentScanner) - RustContentScanner( - client = client, - rustScanner = contentScanner, - ) - } + val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null)) + val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId())) + .getOrNull() + ?.let { contentScannerUrl -> + val contentScanner = ContentScanner(contentScannerUrl) + client.setContentScanner(contentScanner) + RustContentScanner( + client = client, + rustScanner = contentScanner, + ) } val syncService = client.syncService() .withSharePos(true) .withOfflineMode() + .withProfilesExtension() .finish() return RustMatrixClient( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt new file mode 100644 index 0000000000..54e4f696d3 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClient.kt @@ -0,0 +1,36 @@ +/* + * 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.libraries.matrix.impl + +import io.element.android.libraries.core.extensions.mapFailure +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.TemporaryMatrixClient +import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.impl.exception.mapClientException +import org.matrix.rustcomponents.sdk.Client + +class RustTemporaryMatrixClient( + private val client: Client, + private val paths: SessionPaths?, +) : TemporaryMatrixClient { + override suspend fun getUrl(url: String): Result = runCatchingExceptions { + client.getUrl(url) + }.mapFailure { it.mapClientException() } + + override fun close() { + client.close() + paths?.let { + if (it.fileDirectory.exists()) { + it.fileDirectory.deleteRecursively() + } + if (it.cacheDirectory.exists()) { + it.cacheDirectory.deleteRecursively() + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt new file mode 100644 index 0000000000..70903ba91a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt @@ -0,0 +1,36 @@ +/* + * 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.libraries.matrix.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.TemporaryMatrixClient +import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory +import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory + +@ContributesBinding(AppScope::class) +class RustTemporaryMatrixClientFactory( + private val sessionPathsFactory: SessionPathsFactory, + private val rustMatrixClientFactory: RustMatrixClientFactory, +) : TemporaryMatrixClientFactory { + override suspend fun create(homeServerUrl: String): Result { + return runCatchingExceptions { + val sessionPaths = sessionPathsFactory.create() + val client = rustMatrixClientFactory.getBaseClientBuilder( + sessionPaths = sessionPaths, + clientSecret = null, + slidingSyncType = ClientBuilderSlidingSync.Native, + isMessageSearchAvailable = false, + ) + .homeserverUrl(homeServerUrl) + .build() + RustTemporaryMatrixClient(client, sessionPaths) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index dd9f73c381..2c936c9b23 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync import io.element.android.libraries.matrix.impl.RustMatrixClientFactory +import io.element.android.libraries.matrix.impl.RustTemporaryMatrixClient import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep @@ -133,9 +134,10 @@ class RustMatrixAuthenticationService( } currentClient = client + client.homeserverLoginDetails().map() }.onFailure { - clear() + clear(destroyClient = true) }.mapFailure { failure -> Timber.e(failure, "Failed to set homeserver to $homeserver") failure.mapAuthenticationException() @@ -168,7 +170,7 @@ class RustMatrixAuthenticationService( sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary - currentClient = null + clear(destroyClient = false) SessionId(sessionData.userId) }.mapFailure { failure -> @@ -248,7 +250,7 @@ class RustMatrixAuthenticationService( sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary - currentClient = null + clear(destroyClient = false) SessionId(sessionData.userId) } @@ -271,11 +273,13 @@ class RustMatrixAuthenticationService( deviceId = null, additionalScopes = emptyList(), ) + val getUrlResolver = RustTemporaryMatrixClient(client, sessionPaths) val url = oAuthAuthorizationData.loginUrl() .let { enterpriseService.tweakMasUrl( url = it, homeserver = client.server() ?: client.homeserver(), + urlContentFetcher = getUrlResolver, ) } pendingOAuthAuthorizationData = oAuthAuthorizationData @@ -335,7 +339,7 @@ class RustMatrixAuthenticationService( sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary - currentClient = null + clear(destroyClient = false) SessionId(sessionData.userId) }.mapFailure { failure -> @@ -398,7 +402,7 @@ class RustMatrixAuthenticationService( sessionStore.addSession(sessionData) // Clean up the strong reference held here since it's no longer necessary - currentClient = null + clear(destroyClient = false) SessionId(sessionData.userId) }.mapFailure { @@ -464,8 +468,10 @@ class RustMatrixAuthenticationService( .build() } - private fun clear() { - currentClient?.close() + private fun clear(destroyClient: Boolean) { + if (destroyClient) { + currentClient?.close() + } currentClient = null } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index d4f49af416..46fa885d25 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -15,6 +15,8 @@ import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.UrlContentFetcher +import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.media.MatrixMediaLoader @@ -37,6 +39,11 @@ object SessionMatrixModule { return matrixClient.sessionId } + @Provides + fun providesDeviceId(matrixClient: MatrixClient): DeviceId { + return matrixClient.deviceId + } + @Provides fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService { return matrixClient.sessionVerificationService @@ -102,4 +109,9 @@ object SessionMatrixModule { fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? { return matrixClient.contentScanner } + + @Provides + fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher { + return matrixClient + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt new file mode 100644 index 0000000000..f4f17fb9a7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustContinuationMessageSender.kt @@ -0,0 +1,36 @@ +/* + * 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.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import timber.log.Timber +import org.matrix.rustcomponents.sdk.ContinuationMessageSender as FfiContinuationMessageSender + +class RustContinuationMessageSender( + private val inner: FfiContinuationMessageSender, + private val sessionDispatcher: CoroutineDispatcher, +) : ContinuationMessageSender { + override suspend fun cancel(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.cancel() + }.onFailure { + Timber.e(it, "Error cancelling continuation message") + } + } + + override suspend fun confirm(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.confirm() + }.onFailure { + Timber.e(it, "Error confirming continuation message") + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt index 83e657eb07..8c9695ae4b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -75,6 +75,10 @@ class RustLinkDesktopHandler( GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth( verificationUri = verificationUri, + continuationMessageSender = RustContinuationMessageSender( + inner = continuationSender, + sessionDispatcher = sessionDispatcher, + ) ) is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel( checkCode = checkCode, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt index a44a8d5943..33f1ad736d 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -84,7 +84,13 @@ class RustLinkMobileHandler( ) GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets - is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri) + is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth( + verificationUri = verificationUri, + continuationMessageSender = RustContinuationMessageSender( + inner = continuationSender, + sessionDispatcher = sessionDispatcher, + ) + ) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index e4914019ea..83b4aa7570 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -40,11 +40,11 @@ class TimelineEventToNotificationContentMapper { private fun TimelineEventContent.toContent(senderId: UserId): NotificationContent { return when (this) { is TimelineEventContent.MessageLike -> content.toContent(senderId) - is TimelineEventContent.State -> content.toContent() + is TimelineEventContent.State -> content.toContent(senderId) } } -private fun StateEventContent.toContent(): NotificationContent.StateEvent { +private fun StateEventContent.toContent(senderId: UserId): NotificationContent.StateEvent { return when (this) { StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer @@ -71,7 +71,7 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent { is StateEventContent.RoomTopic -> NotificationContent.StateEvent.RoomTopic(topic) StateEventContent.SpaceChild -> NotificationContent.StateEvent.SpaceChild StateEventContent.SpaceParent -> NotificationContent.StateEvent.SpaceParent - StateEventContent.BeaconInfo -> NotificationContent.StateEvent.BeaconInfo + StateEventContent.BeaconInfo -> NotificationContent.StateEvent.BeaconInfo(senderId) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 68fcf6287c..d454d7c775 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListServ import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.RustTimeline import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper +import io.element.android.libraries.matrix.impl.user.map import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt index a972b3c130..33d7017ff9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt @@ -16,12 +16,15 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper import io.element.android.libraries.matrix.impl.room.tombstone.map +import io.element.android.libraries.matrix.impl.user.from +import io.element.android.libraries.matrix.impl.user.into import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import org.matrix.rustcomponents.sdk.Membership @@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) { fun RoomHero.map(): MatrixUser = MatrixUser( userId = UserId(userId), displayName = displayName, - avatarUrl = avatarUrl + avatarUrl = avatarUrl, + displayedStatus = DisplayedStatus.from(status, call), + rawStatus = status?.into(), ) fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt index b2a530f806..bcff7fc153 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt @@ -11,7 +11,9 @@ package io.element.android.libraries.matrix.impl.room.member import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.impl.room.powerlevels.into +import io.element.android.libraries.matrix.impl.user.from import uniffi.matrix_sdk.RoomMemberRole import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember @@ -30,8 +32,7 @@ object RoomMemberMapper { role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel), membershipChangeReason = roomMember.membershipChangeReason, isServiceMember = roomMember.isServiceMember, - // TODO map this value when available - displayedStatus = null, + displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call), ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt index 801d5fd146..3a568761ce 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventTimelineItemMapper.kt @@ -21,7 +21,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetail import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender import io.element.android.libraries.matrix.api.timeline.item.event.Receipt import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.impl.core.RustSendHandle +import io.element.android.libraries.matrix.impl.user.from import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails { displayName = displayName, displayNameAmbiguous = displayNameAmbiguous, avatarUrl = avatarUrl, - // TODO map this value when available - displayedStatus = null, + displayedStatus = DisplayedStatus.from(status, call), ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserProfileMapper.kt similarity index 71% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserProfileMapper.kt index 14c813548a..c9c64d83b6 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserProfileMapper.kt @@ -6,9 +6,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.impl.mapper +package io.element.android.libraries.matrix.impl.user import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.MatrixUser import org.matrix.rustcomponents.sdk.UserProfile @@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser( userId = UserId(userId), displayName = displayName, avatarUrl = avatarUrl, + rawStatus = status?.into(), + displayedStatus = DisplayedStatus.from(status, call) ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserSearchResultMapper.kt similarity index 86% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserSearchResultMapper.kt index 5a6481b4f8..f9b1503c8c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserSearchResultMapper.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.impl.usersearch +package io.element.android.libraries.matrix.impl.user import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults -import io.element.android.libraries.matrix.impl.mapper.map import kotlinx.collections.immutable.toImmutableList import org.matrix.rustcomponents.sdk.SearchUsersResults diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserStatus.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserStatus.kt new file mode 100644 index 0000000000..1b8a738bf2 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/user/UserStatus.kt @@ -0,0 +1,29 @@ +/* + * 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.libraries.matrix.impl.user + +import io.element.android.libraries.matrix.api.user.DisplayedStatus +import io.element.android.libraries.matrix.api.user.UserStatus +import org.matrix.rustcomponents.sdk.UserCall +import org.matrix.rustcomponents.sdk.UserStatus as RustUserStatus + +fun UserStatus.into(): RustUserStatus { + return RustUserStatus(emoji = emoji, text = text) +} + +fun RustUserStatus.into(): UserStatus { + return UserStatus(emoji = emoji, text = text) +} + +fun DisplayedStatus.Companion.from(userStatus: RustUserStatus?, userCall: UserCall?): DisplayedStatus? { + return when { + userStatus != null -> DisplayedStatus.UserSet(userStatus.into()) + userCall != null -> DisplayedStatus.InCall(userCall.callJoinedTs?.toLong()) + else -> null + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt index d88f9fd6ce..c3b6d33b82 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/SessionVerificationRequestDetails.kt @@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.VerificationRequest -import io.element.android.libraries.matrix.impl.mapper.map +import io.element.android.libraries.matrix.impl.user.map import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index d1536d949e..5e5000aedc 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -52,7 +52,7 @@ fun TestScope.createRustMatrixClientFactory( ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) }, + contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -67,5 +67,5 @@ fun TestScope.createRustMatrixClientFactory( clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), workManagerScheduler = workManagerScheduler, - contentScannerUrlProvider = contentScannerUrlProvider, + contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index 4ea4bb64e3..ea399b39df 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -32,9 +32,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock 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.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client @@ -44,6 +44,9 @@ import org.matrix.rustcomponents.sdk.StoreSizes import org.matrix.rustcomponents.sdk.UserProfile import java.io.File +private const val AN_ACCOUNT_DATA_EVENT_TYPE = "org.example.custom" +private const val AN_ACCOUNT_DATA_CONTENT = """{"key":"value"}""" + class RustMatrixClientTest { @Test fun `ensure that sessionId and deviceId can be retrieved from the client`() = runTest { @@ -72,7 +75,8 @@ class RustMatrixClientTest { @Test fun `retrieving the UserProfile updates the database`() = runTest { - val updateUserProfileResult = lambdaRecorder { _, _, _ -> } + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _ -> profilePersisted.complete(Unit) } val sessionStore = InMemorySessionStore( initialList = listOf( aSessionData( @@ -97,7 +101,7 @@ class RustMatrixClientTest { ), sessionStore = sessionStore, ) - advanceUntilIdle() + profilePersisted.await() updateUserProfileResult.assertions().isCalledOnce() .with( value(A_USER_ID.value), @@ -138,6 +142,30 @@ class RustMatrixClientTest { assertThat(createParameters?.historyVisibilityOverride).isEqualTo(RoomHistoryVisibility.Invited) } + @Test + fun `getAccountData returns the raw content provided by the client`() = runTest { + val accountDataResult = lambdaRecorder { AN_ACCOUNT_DATA_CONTENT } + val client = createRustMatrixClient( + client = FakeFfiClient(accountDataResult = accountDataResult) + ) + + assertThat(client.getAccountData(AN_ACCOUNT_DATA_EVENT_TYPE).getOrThrow()).isEqualTo(AN_ACCOUNT_DATA_CONTENT) + accountDataResult.assertions().isCalledOnce().with(value(AN_ACCOUNT_DATA_EVENT_TYPE)) + client.destroy() + } + + @Test + fun `setAccountData forwards the event type and the raw content to the client`() = runTest { + val setAccountDataResult = lambdaRecorder { _, _ -> } + val client = createRustMatrixClient( + client = FakeFfiClient(setAccountDataResult = setAccountDataResult) + ) + + assertThat(client.setAccountData(AN_ACCOUNT_DATA_EVENT_TYPE, AN_ACCOUNT_DATA_CONTENT).isSuccess).isTrue() + setAccountDataResult.assertions().isCalledOnce().with(value(AN_ACCOUNT_DATA_EVENT_TYPE), value(AN_ACCOUNT_DATA_CONTENT)) + client.destroy() + } + private fun TestScope.createRustMatrixClient( client: Client = FakeFfiClient(), sessionStore: SessionStore = InMemorySessionStore( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt new file mode 100644 index 0000000000..a8843a9ca2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -0,0 +1,84 @@ +/* + * 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.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider +import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider +import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider +import io.element.android.libraries.network.useragent.SimpleUserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File + +class RustTemporaryMatrixClientFactoryTest { + @Test + fun `create returns a TemporaryMatrixClient`() = runTest { + val sut = createRustTemporaryMatrixClientFactory() + val result = sut.create("https://matrix.org") + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `create can fail gracefully`() = runTest { + val sut = createRustTemporaryMatrixClientFactory( + rustMatrixClientFactory = createRustMatrixClientFactory( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { throw IllegalStateException("Failed to create client builder") } + ) + ) + ) + val result = sut.create("https://matrix.org") + assertThat(result.isFailure).isTrue() + } + + private fun TestScope.createRustTemporaryMatrixClientFactory( + sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")), + rustMatrixClientFactory: RustMatrixClientFactory = createRustMatrixClientFactory(), + ): RustTemporaryMatrixClientFactory { + return RustTemporaryMatrixClientFactory( + sessionPathsFactory = sessionPathsFactory, + rustMatrixClientFactory = rustMatrixClientFactory, + ) + } + + private fun TestScope.createRustMatrixClientFactory( + cacheDirectory: File = File("/cache"), + sessionStore: SessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, + ) = RustMatrixClientFactory( + cacheDirectory = cacheDirectory, + appCoroutineScope = backgroundScope, + coroutineDispatchers = testCoroutineDispatchers(), + sessionStore = sessionStore, + userAgentProvider = SimpleUserAgentProvider(), + proxyProvider = FakeProxyProvider(), + clock = FakeSystemClock(), + analyticsService = FakeAnalyticsService(), + featureFlagService = FakeFeatureFlagService(), + timelineEventFilterFactory = FakeTimelineEventFilterFactory(), + clientBuilderProvider = clientBuilderProvider, + sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), + workManagerScheduler = workManagerScheduler, + contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt new file mode 100644 index 0000000000..322103042b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt @@ -0,0 +1,92 @@ +/* + * 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.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.NoHandle +import java.io.File +import kotlin.io.path.createTempDirectory +import org.matrix.rustcomponents.sdk.ClientException as RustClientException + +class RustTemporaryMatrixClientTest { + @Test + fun `getUrl method uses client getUrl`() = runTest { + val paths = SessionPaths( + fileDirectory = File("files"), + cacheDirectory = File("cache"), + ) + + val getUrlRecorder = lambdaRecorder { ByteArray(0) } + val client = createRustTemporaryMatrixClient( + client = FakeFfiClient( + getUrlResult = getUrlRecorder, + ), + paths = paths, + ) + val url = "https://example.com" + + client.getUrl(url) + + getUrlRecorder.assertions().isCalledOnce().with(value(url)) + } + + @Test + fun `getUrl method can fail gracefully and map errors to public ones`() = runTest { + val paths = SessionPaths( + fileDirectory = File("files"), + cacheDirectory = File("cache"), + ) + + val getUrlRecorder = lambdaRecorder { throw RustClientException.Generic("Error", null) } + val client = createRustTemporaryMatrixClient( + client = FakeFfiClient( + getUrlResult = getUrlRecorder, + ), + paths = paths, + ) + val url = "https://example.com" + + val result = client.getUrl(url) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(ClientException.Generic::class.java) + } + + @Test + fun `close deletes the directories if they exist`() = runTest { + val paths = SessionPaths( + fileDirectory = createTempDirectory().toFile(), + cacheDirectory = createTempDirectory().toFile(), + ) + + val client = createRustTemporaryMatrixClient(paths = paths) + + assertThat(paths.fileDirectory.exists()).isTrue() + assertThat(paths.cacheDirectory.exists()).isTrue() + + client.close() + + assertThat(paths.fileDirectory.exists()).isFalse() + assertThat(paths.cacheDirectory.exists()).isFalse() + } + + private fun createRustTemporaryMatrixClient( + client: Client = Client(NoHandle), + paths: SessionPaths, + ): RustTemporaryMatrixClient { + return RustTemporaryMatrixClient(client, paths) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index f5f3ffd10b..e349c616c0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvide import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -50,6 +51,29 @@ class RustMatrixAuthenticationServiceTest { assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() } + @Test + fun `setHomeserver can fail gracefully and clean up the temporary client`() = runTest { + val closeResult = lambdaRecorder {} + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + homeserverLoginDetailsResult = { + throw IllegalStateException("Failed to get homeserver login details") + }, + closeResult = closeResult, + ) + }, + ) + }, + ), + ) + assertThat(sut.setHomeserver("matrix.org").isFailure).isTrue() + closeResult.assertions().isCalledOnce() + } + private fun TestScope.createRustMatrixAuthenticationService( sessionStore: SessionStore = InMemorySessionStore(), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index efae379415..13be3cf2d5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationSettings +import org.matrix.rustcomponents.sdk.ProfileListener import org.matrix.rustcomponents.sdk.PusherIdentifiers import org.matrix.rustcomponents.sdk.PusherKind import org.matrix.rustcomponents.sdk.RoomDirectorySearch @@ -55,6 +56,11 @@ class FakeFfiClient( private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), + private val accountDataResult: (String) -> String? = { lambdaError() }, + private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() }, + private val isUserStatusSupportedResult: () -> Boolean = { false }, + private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, + private val getUrlResult: (String) -> ByteArray = { lambdaError() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -92,6 +98,13 @@ class FakeFfiClient( return FakeFfiTaskHandle() } + override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult() + + override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle { + subscribeToOwnProfileResult(listener) + return FakeFfiTaskHandle() + } + override suspend fun getProfile(userId: String): UserProfile { return getProfileResult(userId) } @@ -114,5 +127,17 @@ class FakeFfiClient( return homeserverCapabilities } + override suspend fun accountData(eventType: String): String? = simulateLongTask { + accountDataResult(eventType) + } + + override suspend fun setAccountData(eventType: String, content: String) = simulateLongTask { + setAccountDataResult(eventType, content) + } + + override suspend fun getUrl(url: String): ByteArray = simulateLongTask { + getUrlResult(url) + } + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt index 2cb67f2519..5a2615aa2b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiSyncServiceBuilder.kt @@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) { override fun withOfflineMode(): SyncServiceBuilder = this override fun withSharePos(enable: Boolean): SyncServiceBuilder = this + override fun withProfilesExtension(): SyncServiceBuilder = this override suspend fun finish(): SyncService = FakeFfiSyncService() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt new file mode 100644 index 0000000000..d1544ec8bb --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeContinuationMessageSender.kt @@ -0,0 +1,25 @@ +/* + * 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.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.ContinuationMessageSender +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeContinuationMessageSender( + private val cancelResult: () -> Result = { lambdaError() }, + private val confirmResult: () -> Result = { lambdaError() }, +) : ContinuationMessageSender { + override suspend fun cancel(): Result = simulateLongTask { + cancelResult() + } + + override suspend fun confirm(): Result = simulateLongTask { + confirmResult() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt index c0781972ac..8a0a046946 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt @@ -16,6 +16,8 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuationMessageSender import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.test.QR_CODE_DATA +import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -50,16 +52,18 @@ class RustLinkDesktopHandlerTest { runCurrent() // progress from the handler is mapped and emitted listOf( - GrantQrLoginProgress.Starting to LinkDesktopStep.Starting, - GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets, + GrantQrLoginProgress.Starting to ExpectedResult(LinkDesktopStep.Starting), + GrantQrLoginProgress.SyncingSecrets to ExpectedResult(LinkDesktopStep.SyncingSecrets), GrantQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) - to LinkDesktopStep.WaitingForAuth("aVerificationUri"), + to ExpectedResult( + resultClass = LinkDesktopStep.WaitingForAuth::class.java, + ), GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1") - to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"), - GrantQrLoginProgress.Done to LinkDesktopStep.Done, - ).forEach { (progress, expectedStep) -> + to ExpectedResult(LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1")), + GrantQrLoginProgress.Done to ExpectedResult(LinkDesktopStep.Done), + ).forEach { (progress, expectedResult) -> handler.emitScanProgress(progress) - assertThat(awaitItem()).isEqualTo(expectedStep) + assertThat(awaitItem()).match(expectedResult) } // scan returns, no new event is emitted completable.complete(Unit) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt index 8c0306c8ad..f1f568e3ca 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE +import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -51,18 +53,18 @@ class RustLinkMobileHandlerTest { runCurrent() // progress from the handler is mapped and emitted listOf( - GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java, - GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java, + GrantGeneratedQrLoginProgress.Starting to ExpectedResult(LinkMobileStep.Starting), + GrantGeneratedQrLoginProgress.SyncingSecrets to ExpectedResult(LinkMobileStep.SyncingSecrets), GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri", FakeFfiContinuationMessageSender()) - to LinkMobileStep.WaitingForAuth::class.java, + to ExpectedResult(LinkMobileStep.WaitingForAuth::class.java), GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender()) - to LinkMobileStep.QrScanned::class.java, + to ExpectedResult(LinkMobileStep.QrScanned::class.java), GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE })) - to LinkMobileStep.QrReady::class.java, - GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java, - ).forEach { (progress, expectedStepClass) -> + to ExpectedResult(LinkMobileStep.QrReady::class.java), + GrantGeneratedQrLoginProgress.Done to ExpectedResult(LinkMobileStep.Done), + ).forEach { (progress, expectedResult) -> handler.emitGenerateProgress(progress) - assertThat(awaitItem()).isInstanceOf(expectedStepClass) + assertThat(awaitItem()).match(expectedResult) } // generate returns, no new event is emitted completable.complete(Unit) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt index 61e5886356..4ace520212 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/UserProfileMapperTest.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile +import io.element.android.libraries.matrix.impl.user.map import io.element.android.libraries.matrix.test.A_USER_ID import org.junit.Test diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustMessageSearchTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustMessageSearchTest.kt index 38787f4724..693ed1bdc2 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustMessageSearchTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustMessageSearchTest.kt @@ -5,6 +5,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.libraries.matrix.impl.search import com.google.common.truth.Truth.assertThat @@ -14,6 +16,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSearchServ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt index 3926b93d12..9134fdb6c7 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/usersearch/UserSearchResultMapperTest.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile +import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper import io.element.android.libraries.matrix.test.A_USER_ID import kotlinx.collections.immutable.toImmutableList import org.junit.Test diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index cdf7cc85cf..56d25ed175 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -124,12 +124,14 @@ class FakeMatrixClient( private val getJoinedRoomIdsResult: () -> Result> = { Result.success(emptySet()) }, private val getRecentEmojisLambda: () -> Result> = { Result.success(emptyList()) }, private val addRecentEmojiLambda: (String) -> Result = { Result.success(Unit) }, + private val getAccountDataLambda: (String) -> Result = { lambdaError() }, + private val setAccountDataLambda: (String, String) -> Result = { _, _ -> lambdaError() }, private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, private val markAllRoomsAsReadResult: () -> Result = { Result.success(Unit) }, private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, - private val getMapStyleUrlResult: () -> Result = { lambdaError() }, private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, + private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, override val contentScanner: ContentScanner? = null, ) : MatrixClient { var setDisplayNameCalled: Boolean = false @@ -144,6 +146,7 @@ class FakeMatrixClient( private set var setUserStatusResult: Result = Result.success(Unit) var clearUserStatusResult: Result = Result.success(Unit) + var isUserStatusSupportedResult: Result = Result.success(true) private val _userProfile: MutableStateFlow = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl)) override val userProfile: StateFlow = _userProfile @@ -288,6 +291,10 @@ class FakeMatrixClient( return clearUserStatusResult } + override suspend fun isUserStatusSupported(): Result = isUserStatusSupportedResult + + override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled) + override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result { @@ -407,6 +414,14 @@ class FakeMatrixClient( return getRecentEmojisLambda() } + override suspend fun getAccountData(eventType: String): Result { + return getAccountDataLambda(eventType) + } + + override suspend fun setAccountData(eventType: String, content: String): Result { + return setAccountDataLambda(eventType, content) + } + override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result { return markRoomAsFullyReadResult(roomId, eventId) } @@ -419,10 +434,6 @@ class FakeMatrixClient( return performDatabaseVacuumLambda() } - override suspend fun getMapStyleUrl(): Result = simulateLongTask { - getMapStyleUrlResult() - } - override suspend fun canLinkNewDevice(): Result = simulateLongTask { return canLinkNewDeviceResult() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt new file mode 100644 index 0000000000..83bde4653a --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClient.kt @@ -0,0 +1,20 @@ +/* + * 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.libraries.matrix.test + +import io.element.android.libraries.matrix.api.TemporaryMatrixClient +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeTemporaryMatrixClient( + private val getUrlResult: (String) -> Result = { lambdaError() }, + private val closeLambda: () -> Unit = {}, +) : TemporaryMatrixClient { + override suspend fun getUrl(url: String): Result = getUrlResult(url) + + override fun close() = closeLambda() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt new file mode 100644 index 0000000000..cdec68bdd1 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeTemporaryMatrixClientFactory.kt @@ -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.libraries.matrix.test + +import io.element.android.libraries.matrix.api.TemporaryMatrixClient +import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory + +class FakeTemporaryMatrixClientFactory( + private val createResult: (String) -> Result = { Result.success(FakeTemporaryMatrixClient()) }, +) : TemporaryMatrixClientFactory { + override suspend fun create(homeServerUrl: String): Result = createResult(homeServerUrl) +} diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt index 62b169744b..228fbd2cff 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt @@ -11,8 +11,24 @@ import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +/** + * Caches one Coil image loader per session, since loading Matrix media requires the session's own client to fetch and decrypt it. + */ interface ImageLoaderHolder { + /** Returns a loader that can only fetch ordinary URLs, for images that do not come from a Matrix room. */ fun get(): ImageLoader + + /** + * Returns the loader of a session, creating it on first use. + * + * @param client the session whose media the loader will fetch. + */ fun get(client: MatrixClient): ImageLoader + + /** + * Drops the cached loader of a session, to be called when that session goes away. + * + * @param sessionId the session whose loader is dropped. + */ fun remove(sessionId: SessionId) } diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt index e6ff5d762d..f145383e87 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt @@ -14,6 +14,15 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData * Generates a bitmap for an initials avatar based on the provided [io.element.android.libraries.designsystem.components.avatar.AvatarData]. */ interface InitialsAvatarBitmapGenerator { + /** + * Draws the initials avatar, used where a bitmap is required rather than a Composable, such as in notifications and shortcuts. + * + * @param size the width and height of the bitmap in pixels. + * @param avatarData the name and id the initials and the background colour are derived from. + * @param useDarkTheme whether to draw the dark theme variant. + * @param fontSizePercentage the text height as a fraction of [size]. + * @return the bitmap, or `null` when it could not be drawn. + */ fun generateBitmap( size: Int, avatarData: AvatarData, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt index 5a9e56829c..e3f6478b76 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -96,7 +96,7 @@ private fun AvatarActionBottomSheetContent( ) { action -> ListItem( modifier = Modifier.clickable { onActionClick(action) }, - headlineContent = { + content = { Text( text = stringResource(action.titleResId), style = ElementTheme.typography.fontBodyLgRegular, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt index ce516ecd54..31a8f5c646 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt @@ -8,12 +8,12 @@ package io.element.android.libraries.matrix.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment @@ -40,8 +40,8 @@ fun CheckableUserRow( Row( modifier = modifier .fillMaxWidth() - .clickable(role = Role.Checkbox, enabled = enabled) { - onCheckedChange(!checked) + .toggleable(value = checked, role = Role.Checkbox, enabled = enabled) { + onCheckedChange(it) }, verticalAlignment = Alignment.CenterVertically, ) { @@ -66,7 +66,7 @@ fun CheckableUserRow( } } Checkbox( - onCheckedChange = onCheckedChange, + onCheckedChange = null, checked = checked, enabled = enabled, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt index 731d499445..c6a4b9d474 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectRoomInfoProvider.kt @@ -31,6 +31,7 @@ fun aSelectRoomInfo( canonicalAlias: RoomAlias? = null, avatarUrl: String? = null, heroes: ImmutableList = persistentListOf(), + isDm: Boolean = false, isTombstoned: Boolean = false, ) = SelectRoomInfo( roomId = roomId, @@ -38,5 +39,6 @@ fun aSelectRoomInfo( canonicalAlias = canonicalAlias, avatarUrl = avatarUrl, heroes = heroes, + isDm = isDm, isTombstoned = isTombstoned, ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt index 24f4a15200..52d81ae895 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/media/contentvalidation/ContentErrorView.kt @@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons @Composable internal fun ContentErrorView( title: String, - message: String, + message: String?, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues.Zero, onTextLayout: ((TextLayoutResult) -> Unit)? = null, @@ -51,12 +51,14 @@ internal fun ContentErrorView( color = ElementTheme.colors.textCriticalPrimary, style = ElementTheme.typography.fontBodyMdMedium, ) - Text( - text = message, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmRegular, - onTextLayout = onTextLayout, - ) + if (message != null) { + Text( + text = message, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmRegular, + onTextLayout = onTextLayout, + ) + } } } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt index 3072c81a15..45a9ef2427 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView @@ -77,7 +77,7 @@ fun InReplyToView( else -> ReplyToLoadingContent(modifier = modifier) } is InReplyToDetails.Error -> - ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) + ReplyToErrorContent(modifier = modifier) is InReplyToDetails.Loading -> ReplyToLoadingContent(modifier = modifier) } @@ -150,24 +150,15 @@ private fun ReplyToLoadingContent( @Composable private fun ReplyToErrorContent( - data: InReplyToDetails.Error, - maxLines: Int, modifier: Modifier = Modifier, ) { - val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp) - Row( - modifier - .background(ElementTheme.colors.bgCanvasDefault) - .padding(paddings) - ) { - Text( - text = data.message, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textCriticalPrimary, - maxLines = maxLines, - overflow = TextOverflow.Ellipsis, - ) - } + ContentErrorView( + title = stringResource(CommonStrings.error_message_not_found), + message = null, + modifier = modifier, + contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp), + onTextLayout = null, + ) } @Composable diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt index d6981f62fd..fd13140e54 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/SelectRoomInfo.kt @@ -23,6 +23,7 @@ data class SelectRoomInfo( val canonicalAlias: RoomAlias?, val avatarUrl: String?, val heroes: ImmutableList, + val isDm: Boolean, val isTombstoned: Boolean, ) { fun getAvatarData(size: AvatarSize) = AvatarData( @@ -41,5 +42,6 @@ fun RoomInfo.toSelectRoomInfo() = SelectRoomInfo( avatarUrl = avatarUrl, heroes = heroes, canonicalAlias = canonicalAlias, + isDm = isDm, isTombstoned = successorRoom != null, ) diff --git a/libraries/matrixui/src/main/res/values-de/translations.xml b/libraries/matrixui/src/main/res/values-de/translations.xml index d779c15659..816989ed76 100644 --- a/libraries/matrixui/src/main/res/values-de/translations.xml +++ b/libraries/matrixui/src/main/res/values-de/translations.xml @@ -3,5 +3,7 @@ "Einladung senden" "Möchtest du einen Chat mit %1$s starten?" "Einladung senden?" + "Du hast derzeit keine Chats mit dieser Person. Bestätige die Einladung, bevor du fortfährst." + "Mit diesem neuen Kontakt einen Chat starten?" "%1$s (%2$s) hat dich eingeladen" diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt index 1bab693609..185b2f58a0 100644 --- a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt @@ -12,37 +12,79 @@ import android.net.Uri import androidx.activity.result.PickVisualMediaRequest import androidx.compose.runtime.Composable +/** + * Registers the system media and file pickers from Composable code, returning launchers the UI can trigger later. + * + * Every method must be called during composition, not from a click handler; a cancelled pick reports a `null` uri or an empty list. + */ interface PickerProvider { + /** + * Registers a picker for a single photo or video from the gallery. + * + * @param onResult called with the picked media and its MIME type, both `null` when the user cancelled. + */ @Composable fun registerGalleryPicker( onResult: (uri: Uri?, mimeType: String?) -> Unit ): PickerLauncher + /** + * Registers a picker restricted to a single image, used where a video would not be valid, such as for an avatar. + * + * @param onResult called with the picked image, `null` when the user cancelled. + */ @Composable fun registerGalleryImagePicker( onResult: (Uri?) -> Unit ): PickerLauncher + /** + * Registers a picker for several photos or videos at once. + * + * @param onResult called with the picked media, empty when the user cancelled. + */ @Composable fun registerGalleryMultiPicker( onResult: (uris: List) -> Unit ): PickerLauncher> + /** + * Registers a picker for a single file of any kind. + * + * @param mimeType the MIME type to filter on, `*` / `*` to accept anything. + * @param onResult called with the picked file and its MIME type, both `null` when the user cancelled. + */ @Composable fun registerFilePicker( mimeType: String, onResult: (uri: Uri?, mimeType: String?) -> Unit, ): PickerLauncher + /** + * Registers a picker for several files at once. + * + * @param mimeType the MIME type to filter on. + * @param onResult called with the picked files, empty when the user cancelled. + */ @Composable fun registerFileMultiPicker( mimeType: String, onResult: (uris: List) -> Unit, ): PickerLauncher, List> + /** + * Registers a launcher that opens the camera to take a photo. + * + * @param onResult called with the captured photo, `null` when the user cancelled or the capture failed. + */ @Composable fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher + /** + * Registers a launcher that opens the camera to record a video. + * + * @param onResult called with the recorded video, `null` when the user cancelled or the capture failed. + */ @Composable fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher } diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index 17bfe682e3..98ebc5ce50 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaplayer.impl import android.content.Context +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -49,7 +50,7 @@ object SimplePlayerModule { @Provides fun simplePlayerProvider( @ApplicationContext context: Context, - ): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).build()) + ): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).setWakeMode(C.WAKE_MODE_LOCAL).build()) } /** diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt index a5462a6caf..e6b9c7f026 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MaxUploadSizeProvider.kt @@ -12,5 +12,6 @@ package io.element.android.libraries.mediaupload.api * Provides the maximum upload size allowed by the Matrix server. */ fun interface MaxUploadSizeProvider { + /** Returns the maximum size in bytes a single upload may have, as advertised by the homeserver. */ suspend fun getMaxUploadSize(): Result } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt index 976a7057f2..60dbf353eb 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaOptimizationConfigProvider.kt @@ -8,6 +8,10 @@ package io.element.android.libraries.mediaupload.api +/** + * Resolves how aggressively media should be compressed, combining the user's own preferences with the app defaults. + */ fun interface MediaOptimizationConfigProvider { + /** Returns the configuration to pass to the pre-processing and sending calls. */ suspend fun get(): MediaOptimizationConfig } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt index c2bf1e5f5d..a51ebc17b9 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -10,10 +10,18 @@ package io.element.android.libraries.mediaupload.api import android.net.Uri +/** + * Prepares a media file for upload: resizing, transcoding and stripping metadata that would leak information about the sender. + */ interface MediaPreProcessor { /** * Given a [uri] and [mimeType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata. * If [deleteOriginal] is `true`, the file reference by the [uri] will be automatically deleted too when this process finishes. + * + * @param uri the media to process. + * @param mimeType the MIME type of that media. + * @param deleteOriginal whether to delete the source file once processing is done. + * @param mediaOptimizationConfig how aggressively the media should be compressed. * @return a [Result] with the [MediaUploadInfo] containing all the info needed to begin the upload. */ suspend fun process( diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt index 6cca73935e..808575f9e6 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaSender.kt @@ -13,31 +13,62 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline +/** + * Creates a [MediaSender] for the room of the current room scope; see [MediaSenderRoomFactory] when the room is not in scope. + */ fun interface MediaSenderFactory { /** * Create a [MediaSender] for the given [Timeline.Mode], in the Room Scope. + * + * @param timelineMode the timeline the media should be sent to, which matters for threads in particular. */ fun create( timelineMode: Timeline.Mode, ): MediaSender } +/** + * Creates a [MediaSender] for an explicitly given room, used outside the room scope, for instance when sharing into a room. + */ fun interface MediaSenderRoomFactory { /** * Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live. + * + * @param room the room the media should be sent to. */ fun create( room: JoinedRoom, ): MediaSender } +/** + * Sends media to a room, taking care of the pre-processing the timeline API does not do: resizing, transcoding and stripping metadata. + * + * Sending is a two-step process, and the steps can be used separately so the UI can show a preview between them. + * The temporary files produced by pre-processing are only released by [cleanUp]. + */ interface MediaSender { + /** + * Resizes, transcodes and strips the metadata of a media file without sending anything yet. + * + * @param uri the media picked by the user. + * @param mimeType the MIME type of that media. + * @param mediaOptimizationConfig how aggressively the media should be compressed. + */ suspend fun preProcessMedia( uri: Uri, mimeType: String, mediaOptimizationConfig: MediaOptimizationConfig, ): Result + /** + * Sends media that has already gone through [preProcessMedia]. + * + * @param mediaUploadInfo the result of the pre-processing step. + * @param caption the text shown along with the media, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendPreProcessedMedia( mediaUploadInfo: MediaUploadInfo, caption: String?, @@ -45,6 +76,16 @@ interface MediaSender { inReplyToEventId: EventId?, ): Result + /** + * Pre-processes and sends a media file in one go, suspending until the upload finishes. + * + * @param uri the media picked by the user. + * @param mimeType the MIME type of that media. + * @param caption the text shown along with the media, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + * @param mediaOptimizationConfig how aggressively the media should be compressed. + */ suspend fun sendMedia( uri: Uri, mimeType: String, @@ -54,6 +95,14 @@ interface MediaSender { mediaOptimizationConfig: MediaOptimizationConfig, ): Result + /** + * Sends a recorded voice message, which is never compressed and carries a waveform instead of a caption. + * + * @param uri the recorded audio file. + * @param mimeType the MIME type of that recording. + * @param waveForm the amplitudes used to draw the waveform. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendVoiceMessage( uri: Uri, mimeType: String, @@ -61,6 +110,14 @@ interface MediaSender { inReplyToEventId: EventId? = null, ): Result + /** + * Sends several already pre-processed media items as a single gallery message. + * + * @param mediaUploadInfos the pre-processed items, in the order they should appear. + * @param caption the text shown along with the gallery, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendGallery( mediaUploadInfos: List, caption: String?, @@ -68,5 +125,6 @@ interface MediaSender { inReplyToEventId: EventId?, ): Result + /** Deletes the temporary files produced by pre-processing; must be called even when nothing was ultimately sent. */ fun cleanUp() } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt index 152678261c..40611741c9 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfig.kt @@ -34,11 +34,14 @@ internal object VideoCompressorConfigFactory { val newFrameRate = min(originalFrameRate, DEFAULT_FRAME_RATE) // If we need to resize the video, we also want to recalculate the bitrate - val newBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate) + val optimalBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate).toInt() + + val sourceBitrate = metadata?.bitrate?.takeIf { it > 0 } + val newBitrate = sourceBitrate?.let { min(optimalBitrate.toLong(), it).toInt() } ?: optimalBitrate return VideoCompressorConfig( videoCompressorHelper = resizer, - newBitRate = newBitrate.toInt(), + newBitRate = newBitrate, newFrameRate = newFrameRate, ) } diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt index 6af0956541..4309d59209 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/VideoCompressorConfigFactoryTest.kt @@ -33,6 +33,46 @@ class VideoCompressorConfigFactoryTest : RobolectricTest() { assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE) } + @Test + fun `the bitrate is clamped to the source bitrate when the source is already low`() { + val metadata = VideoFileMetadata(width = 720, height = 720, bitrate = 615_000, frameRate = 25, rotation = 0) + + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = VideoCompressionPreset.STANDARD, + ) + + assertThat(videoCompressorConfig.newBitRate).isEqualTo(615_000) + } + + @Test + fun `the bitrate is not raised for a high bitrate source`() { + val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 20_000_000, frameRate = 30, rotation = 0) + + val videoCompressorConfig = VideoCompressorConfigFactory.create( + metadata = metadata, + preset = VideoCompressionPreset.STANDARD, + ) + + assertThat(videoCompressorConfig.newBitRate).isLessThan(20_000_000) + assertThat(videoCompressorConfig.newBitRate).isGreaterThan(0) + } + + @Test + fun `an unknown bitrate falls back to the calculated optimal`() { + val unknown = VideoCompressorConfigFactory.create( + metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = -1, frameRate = 30, rotation = 0), + preset = VideoCompressionPreset.STANDARD, + ) + val zero = VideoCompressorConfigFactory.create( + metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 0, frameRate = 30, rotation = 0), + preset = VideoCompressionPreset.STANDARD, + ) + + assertThat(unknown.newBitRate).isGreaterThan(0) + assertThat(zero.newBitRate).isEqualTo(unknown.newBitRate) + } + @Test fun `if the video should be compressed and is larger than 720p it will be resized`() { // Given diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt index 383f985c4f..5a218f9e1e 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaFactory.kt @@ -12,9 +12,15 @@ import android.net.Uri import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.mediaviewer.api.MediaInfo +/** + * Builds the [LocalMedia] the media viewer renders, from either a downloaded Matrix media file or an arbitrary content uri. + */ interface LocalMediaFactory { /** * This method will create a [LocalMedia] with the given [MediaFile] and [MediaInfo]. + * + * @param mediaFile the file already downloaded from the homeserver. + * @param mediaInfo what is known about the media from the event that carried it. */ fun createFromMediaFile( mediaFile: MediaFile, @@ -24,6 +30,11 @@ interface LocalMediaFactory { /** * This method will create a [LocalMedia] with the given mimeType, name and formattedFileSize * If any of those params are null, it'll try to read them from the content. + * + * @param uri the content to display, for instance one just picked by the user. + * @param mimeType the MIME type, or `null` to read it from the content. + * @param name the file name, or `null` to read it from the content. + * @param formattedFileSize the size to display, or `null` to read it from the content. */ fun createFromUri( uri: Uri, diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt index fb7f26101e..5bc84919be 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaRenderer.kt @@ -10,7 +10,15 @@ package io.element.android.libraries.mediaviewer.api.local import androidx.compose.runtime.Composable +/** + * Renders a media file held on the device, picking the right player or viewer for its type. + */ interface LocalMediaRenderer { + /** + * Draws the media, filling the space it is given. + * + * @param localMedia the file to display, along with what is known about its type. + */ @Composable fun Render(localMedia: LocalMedia) } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt index 094b4513d8..e29188e984 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/util/FileExtensionExtractor.kt @@ -8,6 +8,13 @@ package io.element.android.libraries.mediaviewer.api.util +/** + * Extracts the extension of a file name, so it can be shown to the user and used to pick an icon. + */ interface FileExtensionExtractor { + /** + * @param name the file name to inspect. + * @return the extension without its dot, or an empty string when the name has none. + */ fun extractFromName(name: String): String } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt index 7bdd4872a3..a123fd2f97 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt @@ -83,7 +83,9 @@ class TimelineMediaGalleryDataSource( }, { Timber.e(it, "Failed to get media timeline for room ${room.roomId}") - groupedMediaItemsFlow.emit(AsyncData.Failure(it)) + if (cache == null) { + groupedMediaItemsFlow.emit(AsyncData.Failure(it)) + } }, ) }.flatMapLatest { timeline -> diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt index c9e11b65bc..35d056dc39 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt @@ -113,14 +113,14 @@ fun MediaDetailsBottomSheet( } ListItem( leadingContent = icon, - headlineContent = { Text(wording) }, + content = { Text(wording) }, onClick = { onOpenWith(state.eventId) } ) ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VisibilityOn())), - headlineContent = { Text(stringResource(CommonStrings.action_view_in_timeline)) }, + content = { Text(stringResource(CommonStrings.action_view_in_timeline)) }, onClick = { onViewInTimeline(state.eventId) } @@ -128,14 +128,14 @@ fun MediaDetailsBottomSheet( if (state.fromGallery) { ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())), - headlineContent = { Text(stringResource(CommonStrings.action_share)) }, + content = { Text(stringResource(CommonStrings.action_share)) }, onClick = { onShare(state.eventId) } ) ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())), - headlineContent = { Text(stringResource(CommonStrings.action_download)) }, + content = { Text(stringResource(CommonStrings.action_download)) }, onClick = { onDownload(state.eventId) } @@ -143,7 +143,7 @@ fun MediaDetailsBottomSheet( } ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())), - headlineContent = { Text(stringResource(CommonStrings.action_forward)) }, + content = { Text(stringResource(CommonStrings.action_forward)) }, onClick = { onForward(state.eventId) } @@ -152,7 +152,7 @@ fun MediaDetailsBottomSheet( HorizontalDivider() ListItem( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())), - headlineContent = { Text(stringResource(CommonStrings.action_delete_file)) }, + content = { Text(stringResource(CommonStrings.action_delete_file)) }, style = ListItemStyle.Destructive, onClick = { onDelete(state.eventId) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 6eca5e9dbc..e1c1a0c250 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -82,7 +82,6 @@ import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.components.BigIcon -import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncLoading import io.element.android.libraries.designsystem.components.button.BackButton @@ -209,7 +208,6 @@ fun MediaViewerView( when (val dataForPage = state.listData[page]) { is MediaViewerPageData.Failure -> { MediaViewerErrorPage( - throwable = dataForPage.throwable, onDismiss = onBackClick, ) } @@ -414,6 +412,7 @@ private fun MediaViewerPage( if (showProgress) { LinearProgressIndicator( modifier = Modifier + .padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top).asPaddingValues()) .fillMaxWidth() .height(2.dp) ) @@ -506,7 +505,6 @@ private fun MediaViewerLoadingPage( @Composable private fun MediaViewerErrorPage( - throwable: Throwable, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -520,9 +518,10 @@ private fun MediaViewerErrorPage( .navigationBarsPadding(), contentAlignment = Alignment.Center ) { - AsyncFailure( - throwable = throwable, - onRetry = null + IconTitleSubtitleMolecule( + iconStyle = BigIcon.Style.AlertSolid, + title = stringResource(CommonStrings.common_something_went_wrong), + subTitle = stringResource(CommonStrings.common_something_went_wrong_message), ) } } diff --git a/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml index 4cbe579e00..0bebdea024 100644 --- a/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/mediaviewer/impl/src/main/res/values-zh-rTW/translations.xml @@ -16,6 +16,7 @@ "檔案名稱" "無可顯示的檔案" "無可顯示的媒體" + "檔案資訊" "上傳者:" "上傳於" diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt index 81bb23d73c..a18bce58c4 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventC import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -171,6 +172,32 @@ class TimelineMediaGalleryDataSourceTest { } } + @Test + fun `test - failing to load a focused timeline keeps the item that was opened`() = runTest { + val room = FakeJoinedRoom( + createTimelineResult = { Result.failure(AN_EXCEPTION) }, + roomCoroutineScope = backgroundScope, + ) + val initialMediaItem = aMediaItemImage() + val sut = createTimelineMediaGalleryDataSource( + room = room, + mediaTimeline = FocusedMediaTimeline( + room = room, + eventId = AN_EVENT_ID, + onlyPinnedEvents = false, + initialMediaItem = initialMediaItem, + ), + ) + sut.start(backgroundScope) + sut.groupedMediaItemsFlow().test { + val item = awaitItem() + assertThat(item.isSuccess()).isTrue() + assertThat(item.dataOrNull()?.imageAndVideoItems).contains(initialMediaItem) + expectNoEvents() + } + assertThat(sut.getLastData().isFailure()).isFalse() + } + @Test fun `test - when timeline emits new data, the flow emits the data`() = runTest { val timelineItems = MutableStateFlow>(emptyList()) @@ -269,10 +296,11 @@ internal fun TestScope.createTimelineMediaGalleryDataSource( room: JoinedRoom = FakeJoinedRoom( liveTimeline = FakeTimeline(), ), + mediaTimeline: MediaTimeline? = null, ): TimelineMediaGalleryDataSource { return TimelineMediaGalleryDataSource( room = room, - mediaTimeline = LiveMediaTimeline(room), + mediaTimeline = mediaTimeline ?: LiveMediaTimeline(room), timelineMediaItemsFactory = createTimelineMediaItemsFactory(), mediaItemsPostProcessor = MediaItemsPostProcessor(), ) diff --git a/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthActionFlow.kt b/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthActionFlow.kt index c6791e3197..7cb35de176 100644 --- a/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthActionFlow.kt +++ b/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthActionFlow.kt @@ -10,8 +10,26 @@ package io.element.android.libraries.oauth.api import kotlinx.coroutines.flow.FlowCollector +/** + * Carries the OAuth redirect from the activity that receives it to the login screen waiting for it. + * + * The two are not in the same navigation subtree, so the result cannot simply be returned. + */ interface OAuthActionFlow { + /** + * Publishes the action just received, to be picked up by whoever is collecting. + * + * @param oAuthAction the action resolved from the redirect intent. + */ fun post(oAuthAction: OAuthAction) + + /** + * Observes the published actions; `null` is emitted when there is nothing pending. + * + * @param collector the collector to feed. + */ suspend fun collect(collector: FlowCollector) + + /** Clears the pending action, so it is not handled twice. */ fun reset() } diff --git a/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthIntentResolver.kt b/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthIntentResolver.kt index 2091a86db4..5187649298 100644 --- a/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthIntentResolver.kt +++ b/libraries/oauth/api/src/main/kotlin/io/element/android/libraries/oauth/api/OAuthIntentResolver.kt @@ -10,6 +10,13 @@ package io.element.android.libraries.oauth.api import android.content.Intent +/** + * Recognises the redirect the authentication server sends the user back with once they have signed in or cancelled. + */ interface OAuthIntentResolver { + /** + * @param intent the intent the app was resumed with. + * @return the action to carry out, or `null` when the intent is not an OAuth redirect. + */ fun resolve(intent: Intent): OAuthAction? } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt index beeef0b234..98fb3edbe0 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt @@ -10,11 +10,40 @@ package io.element.android.libraries.permissions.api import kotlinx.coroutines.flow.Flow +/** + * Answers permission questions by combining the live system state with what [PermissionsStore] remembers about past requests. + */ interface PermissionStateProvider { + /** + * Whether the permission is granted right now, read from the system rather than from the store. + * + * @param permission the Android permission name. + */ fun isPermissionGranted(permission: String): Boolean + + /** + * @param permission the Android permission name. + * @param value true once the user has refused this permission. + */ suspend fun setPermissionDenied(permission: String, value: Boolean) + + /** + * Whether the user has refused this permission before. + * + * @param permission the Android permission name. + */ fun isPermissionDenied(permission: String): Flow + /** + * @param permission the Android permission name. + * @param value true once the system dialog for this permission has been shown. + */ suspend fun setPermissionAsked(permission: String, value: Boolean) + + /** + * Whether the system dialog for this permission has already been shown. + * + * @param permission the Android permission name. + */ fun isPermissionAsked(permission: String): Flow } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt index f2ccb2bfc3..07918d0c86 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsPresenter.kt @@ -11,7 +11,13 @@ package io.element.android.libraries.permissions.api import io.element.android.libraries.architecture.Presenter interface PermissionsPresenter : Presenter { + /** + * Creates a presenter dedicated to one permission. + */ interface Factory { + /** + * @param permission the Android permission name this presenter will request. + */ fun create(permission: String): PermissionsPresenter } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt index 3bb7d2c679..2d37f016f9 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt @@ -10,15 +10,44 @@ package io.element.android.libraries.permissions.api import kotlinx.coroutines.flow.Flow +/** + * Remembers what the app has already asked the user about, since Android itself does not let an app tell + * "never asked" apart from "asked and denied". + */ interface PermissionsStore { + /** + * @param permission the Android permission name. + * @param value true once the user has refused this permission. + */ suspend fun setPermissionDenied(permission: String, value: Boolean) + + /** + * Whether the user has refused this permission before. + * + * @param permission the Android permission name. + */ fun isPermissionDenied(permission: String): Flow + /** + * @param permission the Android permission name. + * @param value true once the system dialog for this permission has been shown. + */ suspend fun setPermissionAsked(permission: String, value: Boolean) + + /** + * Whether the system dialog for this permission has already been shown, which decides whether the app may show a rationale instead. + * + * @param permission the Android permission name. + */ fun isPermissionAsked(permission: String): Flow + /** + * Forgets what was recorded about one permission, so the app behaves as if it had never been requested. + * + * @param permission the Android permission name. + */ suspend fun resetPermission(permission: String) - // To debug + /** Forgets every recorded permission; exposed for debugging from the developer options. */ suspend fun resetStore() } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/localnetwork/LocalNetworkPermissionAdvisor.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/localnetwork/LocalNetworkPermissionAdvisor.kt index 9863dbb751..8d801cfe67 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/localnetwork/LocalNetworkPermissionAdvisor.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/localnetwork/LocalNetworkPermissionAdvisor.kt @@ -7,10 +7,15 @@ package io.element.android.libraries.permissions.api.localnetwork +/** + * Decides whether the local network permission is needed to reach a homeserver, which is the case for a server on the user's own network. + */ interface LocalNetworkPermissionAdvisor { /** * Returns true when the app should request the ACCESS_LOCAL_NETWORK permission before making * network requests to [homeserverUrl]. + * + * @param homeserverUrl the server the app is about to contact. */ suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt index d4ff5b3592..6285c46389 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/AppPreferencesStore.kt @@ -13,29 +13,75 @@ import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import kotlinx.coroutines.flow.Flow +/** + * Local, app-wide user preferences that are not tied to a session, stored on the device only. + * + * Each getter returns a flow that emits the current value straight away and then again on every change. + * The default each preference falls back to when it has never been set is given on the getter. + */ interface AppPreferencesStore { + /** + * @param enabled true to expose the developer options in the settings. + */ suspend fun setDeveloperModeEnabled(enabled: Boolean) + + /** Whether developer mode is on; defaults to `true` on debug builds and `false` otherwise. */ fun isDeveloperModeEnabledFlow(): Flow + /** + * @param string the Element Call deployment to use, or `null` to go back to the one from the homeserver. + */ suspend fun setCustomElementCallBaseUrl(string: String?) + + /** The Element Call URL the user has overridden, or `null` when the homeserver's own value should be used. */ fun getCustomElementCallBaseUrlFlow(): Flow + /** + * @param theme the name of the theme to apply: light, dark, or follow the system. + */ suspend fun setTheme(theme: String) + + /** The name of the chosen theme, or `null` when the user has not picked one and the system setting should be followed. */ fun getThemeFlow(): Flow + /** + * @param value the distance in metres the user must move before a new live location is published. + */ suspend fun setLiveLocationMinimumDistanceInMetersUpdate(value: Int) + + /** The minimum distance in metres between two live location updates; defaults to 10. */ fun getLiveLocationMinimumDistanceInMetersUpdateFlow(): Flow + /** + * Only used to clear the local value once it has been migrated to the server. + * + * @param hide the local setting, or `null` to erase it. + */ @Deprecated("Use MediaPreviewService instead. Kept only for migration.") suspend fun setHideInviteAvatars(hide: Boolean?) + + /** The local setting to migrate to the server, or `null` once there is nothing left to migrate. */ @Deprecated("Use MediaPreviewService instead. Kept only for migration.") fun getHideInviteAvatarsFlow(): Flow + + /** + * Only used to clear the local value once it has been migrated to the server. + * + * @param mediaPreviewValue the local setting, or `null` to erase it. + */ @Deprecated("Use MediaPreviewService instead. Kept only for migration.") suspend fun setTimelineMediaPreviewValue(mediaPreviewValue: MediaPreviewValue?) + + /** The local setting to migrate to the server, or `null` once there is nothing left to migrate. */ @Deprecated("Use MediaPreviewService instead. Kept only for migration.") fun getTimelineMediaPreviewValueFlow(): Flow + /** + * @param logLevel how verbose the SDK and app logs should be. + */ suspend fun setTracingLogLevel(logLevel: LogLevel) + + /** The configured log level; defaults to the one the build was compiled with. */ fun getTracingLogLevelFlow(): Flow /** @@ -52,23 +98,40 @@ interface AppPreferencesStore { */ suspend fun addHomeserverToHistory(url: String) + /** + * @param targets the extra log packs to enable on top of the default targets. + */ suspend fun setTracingLogPacks(targets: Set) + + /** The extra log packs enabled by the user; defaults to an empty set. */ fun getTracingLogPacksFlow(): Flow> + /** The sound played for message notifications. */ fun getMessageSoundFlow(): Flow /** * Atomically persists [sound] (with copy-time [title] for Custom; cleared otherwise) and * bumps the channel version. Single transaction so process death can't desync URI and version. + * + * @param sound the sound to persist. + * @param title the display name captured when the sound was copied, or `null` for the built-in choices. + * @return the new channel version, to be passed to the channel recreation. */ suspend fun setMessageSoundAndIncrementVersion(sound: NotificationSound, title: String?): Int /** Title captured at copy time. Null for SystemDefault / Silent or pre-title persisted data. */ fun getMessageSoundDisplayNameFlow(): Flow + /** The sound played for incoming call notifications. */ fun getCallRingtoneFlow(): Flow - /** See [setMessageSoundAndIncrementVersion]. */ + /** + * See [setMessageSoundAndIncrementVersion]. + * + * @param sound the ringtone to persist. + * @param title the display name captured when the sound was copied, or `null` for the built-in choices. + * @return the new channel version, to be passed to the channel recreation. + */ suspend fun setCallRingtoneAndIncrementVersion(sound: NotificationSound, title: String?): Int /** See [getMessageSoundDisplayNameFlow]. */ @@ -77,5 +140,6 @@ interface AppPreferencesStore { /** Single-snapshot read of all sound prefs; used at boot to seed channels without N reads. */ suspend fun getNotificationSoundChannelConfig(): NotificationSoundChannelConfig + /** Erases every app preference, so they all fall back to their defaults. */ suspend fun reset() } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt index 50f6eb2431..a3d609e205 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/PreferenceDataStoreFactory.kt @@ -17,5 +17,10 @@ import androidx.datastore.preferences.core.Preferences * It's a wrapper around AndroidX's `PreferenceDataStoreFactory` to make testing easier. */ interface PreferenceDataStoreFactory { + /** + * Creates or opens the preference store with the given name. + * + * @param name the store name, which also determines the file it is persisted to. + */ fun create(name: String): DataStore } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt index d3f3fb4e28..b554fff460 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStore.kt @@ -10,30 +10,77 @@ package io.element.android.libraries.preferences.api.store import kotlinx.coroutines.flow.Flow +/** + * Local, per-session user preferences, stored on the device only and never synced to the homeserver. + * + * Each getter returns a flow that emits the current value straight away and then again on every change. + * The default each preference falls back to when it has never been set is given on the getter. + */ interface SessionPreferencesStore { + /** + * @param enabled true to let the homeserver share this user's presence with others. + */ suspend fun setSharePresence(enabled: Boolean) + + /** Whether presence is shared; defaults to `true`. */ fun isSharePresenceEnabled(): Flow + /** + * @param enabled true to send read receipts that other members can see. + */ suspend fun setSendPublicReadReceipts(enabled: Boolean) + + /** Whether read receipts are sent publicly rather than privately; defaults to `true`. */ fun isSendPublicReadReceiptsEnabled(): Flow + /** + * @param enabled true to display the read receipts of other members in the timeline. + */ suspend fun setRenderReadReceipts(enabled: Boolean) + + /** Whether other members' read receipts are displayed; defaults to `true`. */ fun isRenderReadReceiptsEnabled(): Flow + /** + * @param enabled true to tell other members when this user is typing. + */ suspend fun setSendTypingNotifications(enabled: Boolean) + + /** Whether typing notifications are sent; defaults to `true`. */ fun isSendTypingNotificationsEnabled(): Flow + /** + * @param enabled true to display which other members are typing. + */ suspend fun setRenderTypingNotifications(enabled: Boolean) + + /** Whether other members' typing notifications are displayed; defaults to `true`. */ fun isRenderTypingNotificationsEnabled(): Flow + /** + * @param skip true to remember that the user dismissed the session verification prompt. + */ suspend fun setSkipSessionVerification(skip: Boolean) + + /** Whether the user chose to skip verifying this session, so they are not asked again; defaults to `false`. */ fun isSessionVerificationSkipped(): Flow + /** + * @param compress true to downscale images before uploading them. + */ suspend fun setOptimizeImages(compress: Boolean) + + /** Whether images are downscaled before upload; defaults to `true`. */ fun doesOptimizeImages(): Flow + /** + * @param preset the quality and size trade-off to apply when compressing videos. + */ suspend fun setVideoCompressionPreset(preset: VideoCompressionPreset) + + /** The video compression preset; defaults to [VideoCompressionPreset.STANDARD], including when the stored value is unreadable. */ fun getVideoCompressionPreset(): Flow + /** Erases every preference of this session, so they all fall back to their defaults. */ suspend fun clear() } diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt index 2c0038d06e..05b4845026 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/libraries/preferences/api/store/SessionPreferencesStoreFactory.kt @@ -11,7 +11,22 @@ package io.element.android.libraries.preferences.api.store import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.CoroutineScope +/** + * Caches one [SessionPreferencesStore] per session, so that every caller of a given session shares the same instance. + */ interface SessionPreferencesStoreFactory { + /** + * Returns the store of a session, creating it on first use. + * + * @param sessionId the session whose store is requested. + * @param sessionCoroutineScope the scope the store uses for its own work, only honoured when the store is actually created. + */ fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore + + /** + * Drops the cached store of a session, to be called when that session goes away. + * + * @param sessionId the session whose store is dropped. + */ fun remove(sessionId: SessionId) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt index a6fbb47173..eb2ce09545 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/GetCurrentPushProvider.kt @@ -10,6 +10,16 @@ package io.element.android.libraries.push.api import io.element.android.libraries.matrix.api.core.SessionId +/** + * Reads the name of the push provider a session is currently registered with. + * + * This is the low-level accessor used where depending on the whole push feature would be too much; prefer [PushService.getCurrentPushProvider] otherwise. + */ interface GetCurrentPushProvider { + /** + * Returns the stored provider name, or `null` when the session has no push provider selected yet. + * + * @param sessionId the session to read the provider of. + */ suspend fun getCurrentPushProvider(sessionId: SessionId): String? } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index a43ee36403..4a5037d17f 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -16,9 +16,14 @@ import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import kotlinx.coroutines.flow.Flow +/** + * Entry point for push notification setup: choosing a push provider, registering a pusher with the homeserver, and inspecting the push history. + */ interface PushService { /** * Return the current push provider, or null if none. + * + * @param sessionId the session to read the provider of. */ suspend fun getCurrentPushProvider(sessionId: SessionId): PushProvider? @@ -31,6 +36,10 @@ interface PushService { * Will unregister any previous pusher and register a new one with the provided [PushProvider]. * * The method has effect only if the [PushProvider] is different than the current one. + * + * @param matrixClient the session to register the pusher for. + * @param pushProvider the provider to switch to. + * @param distributor the distributor of that provider to use, which matters for providers such as UnifiedPush. */ suspend fun registerWith( matrixClient: MatrixClient, @@ -42,6 +51,8 @@ interface PushService { * Ensure that the pusher with the current push provider and distributor is registered. * If there is no current config, the default push provider with the default distributor will be used. * Error can be [PusherRegistrationFailure]. + * + * @param matrixClient the session to ensure the pusher of. */ suspend fun ensurePusherIsRegistered( matrixClient: MatrixClient, @@ -50,17 +61,35 @@ interface PushService { /** * Store the given push provider as the current one, but do not register. * To be used when there is no distributor available. + * + * @param sessionId the session to store the provider for. + * @param pushProvider the provider to remember. */ suspend fun selectPushProvider( sessionId: SessionId, pushProvider: PushProvider, ) + /** + * Whether the user has asked not to be warned about pusher registration failures for this session. + * + * @param sessionId the session to read the preference of. + */ fun ignoreRegistrationError(sessionId: SessionId): Flow + + /** + * Remembers whether pusher registration failures should be reported to the user for this session. + * + * @param sessionId the session to store the preference for. + * @param ignore true to stop warning the user about registration failures. + */ suspend fun setIgnoreRegistrationError(sessionId: SessionId, ignore: Boolean) /** + * Asks the homeserver to send a test push, so the user can check that the whole chain works. * Return false in case of early error. + * + * @param sessionId the session to test the push setup of. */ suspend fun testPush(sessionId: SessionId): Boolean @@ -86,6 +115,8 @@ interface PushService { /** * Notify the user that the service is un-registered. + * + * @param userId the user whose push service is no longer registered. */ suspend fun onServiceUnregistered(userId: UserId) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt index bded69991b..9dd53ade09 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationBitmapLoader.kt @@ -14,6 +14,9 @@ import coil3.ImageLoader import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +/** + * Loads the avatars used to decorate notifications, since the notification APIs need bitmaps rather than Compose images. + */ interface NotificationBitmapLoader { /** * Get icon of a room. diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt index 0a4e35c697..95966f8eb1 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationCleaner.kt @@ -13,12 +13,56 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId +/** + * Dismisses notifications that are already displayed, typically because the user has now seen the events elsewhere. + * + * Every method also removes the grouping summary notification when it is left without children. + */ interface NotificationCleaner { + /** + * Dismisses every message notification of a session. + * + * @param sessionId the session whose notifications are dismissed. + */ fun clearAllMessagesEvents(sessionId: SessionId) + + /** + * Dismisses the message notifications of one room. + * + * @param sessionId the session the room belongs to. + * @param roomId the room whose notifications are dismissed. + */ fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) + + /** + * Dismisses the message notifications of one thread, leaving the rest of the room's notifications in place. + * + * @param sessionId the session the room belongs to. + * @param roomId the room the thread belongs to. + * @param threadId the thread whose notifications are dismissed. + */ fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) + + /** + * Dismisses the notification of a single event. + * + * @param sessionId the session the event belongs to. + * @param eventId the event whose notification is dismissed. + */ fun clearEvent(sessionId: SessionId, eventId: EventId) + /** + * Dismisses every membership notification of a session, i.e. the invitations and the join requests. + * + * @param sessionId the session whose notifications are dismissed. + */ fun clearMembershipNotificationForSession(sessionId: SessionId) + + /** + * Dismisses the membership notifications of one room. + * + * @param sessionId the session the room belongs to. + * @param roomId the room whose notifications are dismissed. + */ fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt index b23c4122ad..91f50abc07 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationSoundUpdater.kt @@ -14,8 +14,20 @@ import io.element.android.libraries.preferences.api.store.NotificationSound * versioned channel because Android forbids mutating sound after creation. */ interface NotificationSoundUpdater { + /** + * Recreates the message notification channel so it uses [sound]. + * + * @param sound the sound the new channel should play. + * @param version bumped on every change, since a new channel id is the only way to change a channel's sound. + */ fun recreateNoisyChannel(sound: NotificationSound, version: Int) + /** + * Recreates the ringing call notification channel so it uses [sound]; see [recreateNoisyChannel] for why a version is needed. + * + * @param sound the sound the new channel should play. + * @param version bumped on every change to force a new channel id. + */ fun recreateRingingCallChannel(sound: NotificationSound, version: Int) /** Current channel sound classified into [NotificationSound]. Null when the channel doesn't exist. */ diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt index 2ba61925c5..d4e051ebf2 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/OnMissedCallNotificationHandler.kt @@ -18,6 +18,10 @@ import io.element.android.libraries.matrix.api.core.SessionId interface OnMissedCallNotificationHandler { /** * Adds a missed call notification. + * + * @param sessionId the session the call was received on. + * @param roomId the room the call took place in. + * @param eventId the call notification event that went unanswered. */ suspend fun addMissedCallNotification( sessionId: SessionId, diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt index 4ea80e4ce8..b3bffc0147 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/conversations/NotificationConversationService.kt @@ -18,6 +18,12 @@ interface NotificationConversationService { /** * Called when a new message is received in a room. * It should create a new conversation shortcut for this room. + * + * @param sessionId the session the message belongs to. + * @param roomId the room the message was received in. + * @param roomName the name to show on the shortcut, or `null` when the room has none. + * @param roomIsDirect whether the room is a direct message, which changes how the shortcut is presented. + * @param roomAvatarUrl the avatar to show on the shortcut, or `null` when the room has none. */ suspend fun onSendMessage( sessionId: SessionId, @@ -30,12 +36,18 @@ interface NotificationConversationService { /** * Called when a room is left. * It should remove the conversation shortcut for this room. + * + * @param sessionId the session the room belongs to. + * @param roomId the room that was left. */ suspend fun onLeftRoom(sessionId: SessionId, roomId: RoomId) /** * Called when the list of available rooms changes. * It should update the conversation shortcuts accordingly, removing shortcuts for no longer joined rooms. + * + * @param sessionId the session the rooms belong to. + * @param roomIds the rooms the user is currently joined to. */ suspend fun onAvailableRoomsChanged(sessionId: SessionId, roomIds: Set) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt index 2268198353..ad3d73656f 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/sound/NotificationSoundCopier.kt @@ -12,6 +12,12 @@ package io.element.android.libraries.push.api.notifications.sound * FileProvider, so the persisted reference outlives the original source. */ interface NotificationSoundCopier { + /** + * Copies the picked sound into app-private storage, replacing any file previously stored for [slot]. + * + * @param sourceUriString the uri of the sound the user picked. + * @param slot which of the two sounds is being set, since each keeps its own copy. + */ suspend fun copyToAppFiles(sourceUriString: String, slot: SoundSlot): CopyResult /** diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 0b2ae5be2c..fdffad3049 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.media.isPreviewEnabled import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.notification.NotificationData import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType @@ -56,6 +57,7 @@ import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import timber.log.Timber @@ -94,6 +96,7 @@ class DefaultNotifiableEventResolver( private val callNotificationEventResolver: CallNotificationEventResolver, private val fallbackNotificationFactory: FallbackNotificationFactory, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, @@ -117,10 +120,15 @@ class DefaultNotifiableEventResolver( return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications")) } + val otherSessionUserIds = sessionStore.getAllSessions() + .map { UserId(it.userId) } + .minus(sessionId) + .toSet() + // The null check is done above val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) -> notificationData.flatMap { data -> - data.asNotifiableEvent(client, sessionId) + data.asNotifiableEvent(client, sessionId, otherSessionUserIds) } } @@ -136,10 +144,23 @@ class DefaultNotifiableEventResolver( ) } + private fun NotificationContent.senderIdOrNull(): UserId? = when (this) { + is NotificationContent.MessageLike.RoomMessage -> senderId + is NotificationContent.MessageLike.Poll -> senderId + is NotificationContent.MessageLike.CallInvite -> senderId + is NotificationContent.MessageLike.RtcNotification -> senderId + is NotificationContent.Invite -> senderId + else -> null + } + private suspend fun NotificationData.asNotifiableEvent( client: MatrixClient, userId: SessionId, + otherSessionUserIds: Set, ): Result = runCatchingExceptions { + if (content.senderIdOrNull() in otherSessionUserIds) { + throw NotificationResolverException.EventFilteredOut + } when (val content = this.content) { is NotificationContent.MessageLike.RoomMessage -> { val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue().isPreviewEnabled(roomJoinRule) @@ -284,8 +305,8 @@ class DefaultNotifiableEventResolver( // Note: this case will be handled below val redactedEventId = content.redactedEventId if (redactedEventId == null) { - Timber.tag(loggerTag.value).d("redactedEventId is null.") - throw NotificationResolverException.UnknownError("redactedEventId is null") + Timber.tag(loggerTag.value).w("Ignoring redaction notification with no redactedEventId.") + throw NotificationResolverException.EventFilteredOut } else { ResolvedPushEvent.Redaction( sessionId = userId, @@ -299,11 +320,51 @@ class DefaultNotifiableEventResolver( Timber.tag(loggerTag.value).d("Ignoring notification for sticker") throw NotificationResolverException.EventFilteredOut } + is NotificationContent.StateEvent.RoomMemberContent -> { + // MSC4506: the homeserver pushes knocks to users who can act on them. + if (content.membershipState == RoomMembershipState.KNOCK) { + val notifiableMessageEvent = buildNotifiableMessageEvent( + sessionId = userId, + senderId = content.userId, + roomId = roomId, + eventId = eventId, + noisy = isNoisy, + timestamp = this.timestamp, + senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.userId), + body = stringProvider.getString(R.string.notification_knock_request_body), + roomName = roomDisplayName, + roomIsDm = isDm, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + ) + ResolvedPushEvent.Event(notifiableMessageEvent) + } else { + Timber.tag(loggerTag.value).d("Ignoring notification for membership ${content.membershipState}") + throw NotificationResolverException.EventFilteredOut + } + } NotificationContent.MessageLike.Beacon -> { Timber.tag(loggerTag.value).d("Ignoring notification for beacon") throw NotificationResolverException.EventFilteredOut } - is NotificationContent.StateEvent.RoomMemberContent, + is NotificationContent.StateEvent.BeaconInfo -> { + val notifiableEventMessage = buildNotifiableMessageEvent( + sessionId = userId, + senderId = content.senderId, + roomId = roomId, + eventId = eventId, + noisy = isNoisy, + timestamp = this.timestamp, + senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId), + body = stringProvider.getString(R.string.notification_live_location_started_body), + imageUriString = null, + roomName = roomDisplayName, + roomIsDm = isDm, + roomAvatarPath = roomAvatarUrl, + senderAvatarPath = senderAvatarUrl, + ) + ResolvedPushEvent.Event(notifiableEventMessage) + } NotificationContent.StateEvent.PolicyRuleRoom, NotificationContent.StateEvent.PolicyRuleServer, NotificationContent.StateEvent.PolicyRuleUser, @@ -322,8 +383,7 @@ class DefaultNotifiableEventResolver( NotificationContent.StateEvent.RoomTombstone, is NotificationContent.StateEvent.RoomTopic, NotificationContent.StateEvent.SpaceChild, - NotificationContent.StateEvent.SpaceParent, - NotificationContent.StateEvent.BeaconInfo -> { + NotificationContent.StateEvent.SpaceParent -> { Timber.tag(loggerTag.value).d("Ignoring notification for state event ${content.javaClass.simpleName}") throw NotificationResolverException.EventFilteredOut } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 44a9b5ca30..afbbe4af4f 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -196,11 +196,12 @@ class DefaultNotificationCreator( isThread = threadId != null, roomIsGroup = !roomInfo.isDm, ) - messagingStyle.addMessagesFromEvents(events, imageLoader) + val newEvents = messagingStyle.filterOutAlreadyDisplayedEvents(events) + messagingStyle.addMessagesFromEvents(newEvents, imageLoader) return builder .setCategory(category) .setNumber(events.size) - .setOnlyAlertOnce(roomInfo.isUpdated) + .setOnlyAlertOnce(roomInfo.isUpdated || newEvents.isEmpty()) .setWhen(lastMessageTimestamp) // MESSAGING_STYLE sets title and content for API 16 and above devices. .setStyle(messagingStyle) @@ -416,6 +417,15 @@ class DefaultNotificationCreator( .build() } + private fun MessagingStyle.filterOutAlreadyDisplayedEvents( + events: List, + ): List { + val displayedEventIds = messages.mapNotNullTo(mutableSetOf()) { it.extras.getString(MESSAGE_EVENT_ID) } + return events.filter { event -> + event.isSmartReplyError() || displayedEventIds.add(event.eventId.value) + } + } + private suspend fun MessagingStyle.addMessagesFromEvents( events: List, imageLoader: ImageLoader, diff --git a/libraries/push/impl/src/main/res/values-et/translations.xml b/libraries/push/impl/src/main/res/values-et/translations.xml index 49010359cd..6b3d3f01d0 100644 --- a/libraries/push/impl/src/main/res/values-et/translations.xml +++ b/libraries/push/impl/src/main/res/values-et/translations.xml @@ -30,6 +30,7 @@ "Kutse osalema vestluses" "%1$s saatus sulle vestluskutse" + "Hakkas jagama oma asukohta reaalajas" "Mainis sind: %1$s" "Uued sõnumid" diff --git a/libraries/push/impl/src/main/res/values-hr/translations.xml b/libraries/push/impl/src/main/res/values-hr/translations.xml index eead0db104..6c364c6a9f 100644 --- a/libraries/push/impl/src/main/res/values-hr/translations.xml +++ b/libraries/push/impl/src/main/res/values-hr/translations.xml @@ -21,6 +21,10 @@ "Imate%d novu poruku." "Imate %d novi poruka." + + "Dijeljeni privitak %1$d" + "Dijeljeni prilozi %1$d" + "📞 Dolazni poziv" "📹 Dolazni poziv" "** Slanje nije uspjelo – otvorite sobu" @@ -33,6 +37,7 @@ "Pozvao/la te na razgovor" "%1$s pozvao/la te na razgovor" + "Počeli su dijeliti svoju lokaciju uživo" "Korisnik vas je spomenuo: %1$s" "Nove poruke" diff --git a/libraries/push/impl/src/main/res/values-hu/translations.xml b/libraries/push/impl/src/main/res/values-hu/translations.xml index ea500512ad..4ee957bc23 100644 --- a/libraries/push/impl/src/main/res/values-hu/translations.xml +++ b/libraries/push/impl/src/main/res/values-hu/translations.xml @@ -30,6 +30,7 @@ "Meghívta, hogy csevegjen" "%1$s meghívta egy csevegésre" + "Elkezdték megosztani az élő helyzetüket" "Megemlítette Önt: %1$s" "Új üzenetek" diff --git a/libraries/push/impl/src/main/res/values-pl/translations.xml b/libraries/push/impl/src/main/res/values-pl/translations.xml index d8a4077d05..6c04215dd4 100644 --- a/libraries/push/impl/src/main/res/values-pl/translations.xml +++ b/libraries/push/impl/src/main/res/values-pl/translations.xml @@ -22,6 +22,11 @@ "Masz %d nowe wiadomości." "Masz %d nowych wiadomości." + + "Udostępnił %1$d załącznik" + "Udostępnił %1$d załączniki" + "Udostępnił %1$d załączników" + "📞 Połączenie przychodzące" "📹 Połączenie przychodzące" "** Nie udało się wysłać - proszę otworzyć pokój" @@ -34,6 +39,7 @@ "Zaprosił Cię do czatu" "%1$s zaprosił Cię do czatu" + "Rozpoczął udostępnianie swojej lokalizacji" "Wspomniano o Tobie: %1$s" "Nowe wiadomości" diff --git a/libraries/push/impl/src/main/res/values-ro/translations.xml b/libraries/push/impl/src/main/res/values-ro/translations.xml index 2f677568ea..109a50f7aa 100644 --- a/libraries/push/impl/src/main/res/values-ro/translations.xml +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -20,6 +20,11 @@ "Aveți %d mesaje noi." "Aveți %d mesaje noi." + + "%1$d atașament partajat" + "%1$d atașamente partajate" + "%1$d atașamente partajate" + "📞 Apel primit" "Apel primit" "** Trimiterea eșuată - vă rugăm să deschideți camera" @@ -31,6 +36,7 @@ "V-a invitat la o discuție" "%1$s v-a invitat să discutați" + "A început să își partajeze locația live" "%1$s v-a menționat" "Mesaje noi" diff --git a/libraries/push/impl/src/main/res/values-uk/translations.xml b/libraries/push/impl/src/main/res/values-uk/translations.xml index 253bebda78..755654dc62 100644 --- a/libraries/push/impl/src/main/res/values-uk/translations.xml +++ b/libraries/push/impl/src/main/res/values-uk/translations.xml @@ -22,6 +22,11 @@ "У вас %d нових повідомлення." "У вас %d нових повідомлень." + + "Поширено %1$d вкладення" + "Поширено %1$d вкладення" + "Поширено %1$d вкладень" + "📞 Вхідний виклик" "📹 Вхідний виклик" "** Не вдалося надіслати - відкрийте кімнату" diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml index 207f962b90..af3b58c980 100644 --- a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -16,6 +16,9 @@ "您有 %d 則新訊息。" + + "已分享 %1$d 個附件" + "📞 來電" "📹 來電" "** 無法傳送,請開啟聊天室" @@ -26,6 +29,7 @@ "邀請您聊天" "%1$s 邀請您加入聊天" + "已開始分享他們的即時位置" "提及您:%1$s" "新訊息" diff --git a/libraries/push/impl/src/main/res/values-zh/translations.xml b/libraries/push/impl/src/main/res/values-zh/translations.xml index 155c9d8ce6..eacd1a2040 100644 --- a/libraries/push/impl/src/main/res/values-zh/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -16,6 +16,9 @@ "你有 %d 个新消息。" + + "已共享 %1$d 个附件" + "📞 来电" "📹 来电" "** 无法发送——请打开房间" diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index 1620408c15..4977a9cf3d 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -19,6 +19,10 @@ "You have %d new message." "You have %d new messages." + + "Shared %1$d attachment" + "Shared %1$d attachments" + "📞 Incoming call" "📹 Incoming call" "** Failed to send - please open room" diff --git a/libraries/push/impl/src/main/res/values/temporary.xml b/libraries/push/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..494f2ee441 --- /dev/null +++ b/libraries/push/impl/src/main/res/values/temporary.xml @@ -0,0 +1,11 @@ + + + + + "Requested to join" + diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index e196ddb0ec..24e88e72d2 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.test.A_REDACTION_REASON import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_SPACE_NAME import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID_2 @@ -56,6 +57,9 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.toolbox.impl.strings.AndroidStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -416,6 +420,27 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } + @Test + fun `resolve live location share start`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.StateEvent.BeaconInfo( + senderId = A_USER_ID_2, + ), + )) + ) + ) + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Started sharing their live location") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + @Test fun `resolve RoomMemberContent invite room`() = runTest { val sut = createDefaultNotifiableEventResolver( @@ -627,6 +652,28 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } + @Test + fun `resolve RoomMemberContent knock`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.StateEvent.RoomMemberContent( + userId = A_USER_ID_2, + membershipState = RoomMembershipState.KNOCK + ) + )) + ) + ) + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + aNotifiableMessageEvent(body = "Requested to join") + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + @Test fun `resolve RoomMemberContent other`() = runTest { val sut = createDefaultNotifiableEventResolver( @@ -797,7 +844,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { } @Test - fun `resolve RoomRedaction with null redactedEventId should return null`() = runTest { + fun `resolve RoomRedaction with null redactedEventId is filtered out`() = runTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( mapOf( @@ -813,6 +860,112 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() + assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut) + } + + @Test + fun `resolve RoomMessage sent by another session on the same device is filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_SESSION_ID_2, + messageType = TextMessageType("Hello world", null), + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore( + listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut) + } + + @Test + fun `resolve RtcNotification sent by another session on the same device is filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RtcNotification( + senderId = A_SESSION_ID_2, + type = RtcNotificationType.RING, + callIntent = CallIntent.AUDIO, + expirationTimestampMillis = 0, + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore( + listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut) + } + + @Test + fun `resolve RoomMessage sent by a user who is not logged in is not filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_SESSION_ID_2, + messageType = TextMessageType("Hello world", null), + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore(listOf(aSessionData(sessionId = A_SESSION_ID.value))), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.isSuccess).isTrue() + } + + @Test + fun `resolve RoomMessage sent by the receiving session itself is not filtered out`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success( + aNotificationData( + content = NotificationContent.MessageLike.RoomMessage( + senderId = A_SESSION_ID, + messageType = TextMessageType("Hello world", null), + ) + ) + ) + ) + ), + sessionStore = InMemorySessionStore( + listOf( + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + ) + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + assertThat(result.getEvent(request)?.isSuccess).isTrue() } @Test @@ -829,6 +982,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { testNoResults(NotificationContent.MessageLike.KeyVerificationDone) testNoResults(NotificationContent.MessageLike.ReactionContent(relatedEventId = AN_EVENT_ID_2.value)) testNoResults(NotificationContent.MessageLike.Sticker) + testNoResults(NotificationContent.MessageLike.Beacon) testNoResults(NotificationContent.StateEvent.PolicyRuleRoom) testNoResults(NotificationContent.StateEvent.PolicyRuleServer) testNoResults(NotificationContent.StateEvent.PolicyRuleUser) @@ -871,6 +1025,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result>> = Result.success(emptyMap()), callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(), + sessionStore: SessionStore = InMemorySessionStore(), ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context notificationService?.givenGetNotificationsResult(notificationResult) @@ -895,6 +1050,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() { clock = FakeSystemClock(), ), featureFlagService = FakeFeatureFlagService(), + sessionStore = sessionStore, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index 38b387a180..299c5ad213 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_COLOR_INT import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -40,6 +41,7 @@ import io.element.android.libraries.push.impl.notifications.factories.action.Rej import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider @@ -302,6 +304,81 @@ class DefaultNotificationCreatorTest : RobolectricTest() { result.commonAssertions() } + @Test + fun `test createMessagesListNotification does not add an event which is already displayed`() = runTest { + val sut = createNotificationCreator() + val event = aNotifiableMessageEvent(body = "A message") + val existingNotification = sut.createRoomNotification(events = listOf(event)) + assertThat(existingNotification.flags and Notification.FLAG_ONLY_ALERT_ONCE).isEqualTo(0) + val result = sut.createRoomNotification(events = listOf(event), existingNotification = existingNotification) + assertThat(result.messageTexts()).containsExactly("A message") + assertThat(result.flags and Notification.FLAG_ONLY_ALERT_ONCE).isNotEqualTo(0) + } + + @Test + fun `test createMessagesListNotification adds a new event to the already displayed ones`() = runTest { + val sut = createNotificationCreator() + val existingNotification = sut.createRoomNotification(events = listOf(aNotifiableMessageEvent(body = "A message"))) + val result = sut.createRoomNotification( + events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID_2, body = "Another message")), + existingNotification = existingNotification, + ) + assertThat(result.messageTexts()).containsExactly("A message", "Another message").inOrder() + } + + @Test + fun `test createMessagesListNotification adds the smart reply error of an already displayed event`() = runTest { + val sut = createNotificationCreator() + val event = aNotifiableMessageEvent(body = "A message").copy(outGoingMessage = true) + val existingNotification = sut.createRoomNotification(events = listOf(event)) + val result = sut.createRoomNotification( + events = listOf(event.copy(outGoingMessageFailed = true)), + existingNotification = existingNotification, + ) + assertThat(result.messageTexts()).containsExactly("A message", "test").inOrder() + } + + @Test + fun `test createMessagesListNotification does not add an image and its caption twice`() = runTest { + val sut = createNotificationCreator() + val event = aNotifiableMessageEvent(body = "A caption").copy( + imageUriString = "aUri", + imageMimeType = "image/jpeg", + ) + val existingNotification = sut.createRoomNotification(events = listOf(event)) + assertThat(existingNotification.messageTexts()).containsExactly("test", "A caption").inOrder() + val result = sut.createRoomNotification(events = listOf(event), existingNotification = existingNotification) + assertThat(result.messageTexts()).containsExactly("test", "A caption").inOrder() + } + + private suspend fun NotificationCreator.createRoomNotification( + events: List, + existingNotification: Notification? = null, + ) = createMessagesListNotification( + notificationAccountParams = aNotificationAccountParams(), + roomInfo = RoomEventGroupInfo( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + roomDisplayName = "roomDisplayName", + hasSmartReplyError = false, + shouldBing = false, + customSound = null, + isUpdated = false, + ), + threadId = null, + largeIcon = null, + lastMessageTimestamp = 123_456L, + tickerText = "tickerText", + existingNotification = existingNotification, + imageLoader = FakeImageLoader(), + events = events, + ) + + private fun Notification.messageTexts(): List { + val messagingStyle = checkNotNull(NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(this)) + return messagingStyle.messages.map { it.text?.toString() } + } + private fun Notification.commonAssertions( expectedGroup: String? = aMatrixUser().userId.value, expectedCategory: String? = NotificationCompat.CATEGORY_MESSAGE, diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt index 49b578070b..1e53575717 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushHandler.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.pushproviders.api +/** + * Entry point for the pushes coming from any provider, which resolves them into notifications. + */ interface PushHandler { /** * Handle a push received from the provider. @@ -22,7 +25,10 @@ interface PushHandler { ): Boolean /** - * Handle an invalid push received from the provider. + * Handle an invalid push received from the provider, which is recorded in the push history so it can be diagnosed. + * + * @param providerInfo an identifier of the provider that sent the push, for logging and debugging purposes. + * @param data the raw payload that could not be understood. */ suspend fun handleInvalid( providerInfo: String, diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt index 405a83b9b8..ed13d0ab37 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -37,33 +37,54 @@ interface PushProvider { /** * Register the pusher to the homeserver. + * + * @param matrixClient the session to register the pusher for. + * @param distributor the distributor to route the notifications through. */ suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result /** - * Return the current distributor, or null if none. + * Return the current distributor as its raw stored value, or null if none. + * + * @param sessionId the session to read the distributor of. */ suspend fun getCurrentDistributorValue(sessionId: SessionId): String? /** - * Return the current distributor, or null if none. + * Return the current distributor, or null if none, which also happens when the stored one is no longer installed. + * + * @param sessionId the session to read the distributor of. */ suspend fun getCurrentDistributor(sessionId: SessionId): Distributor? /** * Unregister the pusher. + * + * @param matrixClient the session to unregister the pusher of. */ suspend fun unregister(matrixClient: MatrixClient): Result /** * To invoke when the session is deleted. + * + * @param sessionId the session that has been removed, whose provider-side data should be cleaned up. */ suspend fun onSessionDeleted(sessionId: SessionId) + /** + * The gateway and push key currently in use, for display in the troubleshooting screens; `null` when not registered. + * + * @param sessionId the session to read the configuration of. + */ suspend fun getPushConfig(sessionId: SessionId): Config? + /** Whether this provider can renew its push token on demand, which not every provider supports. */ fun canRotateToken(): Boolean + /** + * Renews the push token, which is one of the recovery steps offered when notifications stop arriving. + * Throws when [canRotateToken] is `false`. + */ suspend fun rotateToken(): Result { error("rotateToken() not implemented, you need to override this method in your implementation") } diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt index 989170c975..e1d885fca2 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt @@ -11,14 +11,25 @@ package io.element.android.libraries.pushproviders.api import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.exception.ClientException +/** + * Registers and removes a pusher on the homeserver on behalf of a push provider, so every provider agrees on the parameters sent. + */ interface PusherSubscriber { /** * Register a pusher. Note that failure will be a [RegistrationFailure]. + * + * @param matrixClient the session to register the pusher for. + * @param pushKey the token identifying this device with the gateway. + * @param gateway the URL the homeserver should send the notifications to. */ suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result /** * Unregister a pusher. + * + * @param matrixClient the session to unregister the pusher of. + * @param pushKey the token of the registration to remove. + * @param gateway the gateway of the registration to remove. */ suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt index 00e5649533..0e1566ba59 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -12,6 +12,7 @@ import android.content.Context import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler import kotlinx.coroutines.flow.filter @@ -21,7 +22,7 @@ import org.unifiedpush.android.connector.UnifiedPush import kotlin.time.Duration.Companion.seconds interface RegisterUnifiedPushUseCase { - suspend fun execute(distributor: Distributor, clientSecret: String): Result + suspend fun execute(distributor: Distributor, clientSecret: String, sessionId: SessionId): Result } @ContributesBinding(AppScope::class) @@ -29,11 +30,11 @@ class DefaultRegisterUnifiedPushUseCase( @ApplicationContext private val context: Context, private val endpointRegistrationHandler: EndpointRegistrationHandler, ) : RegisterUnifiedPushUseCase { - override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + override suspend fun execute(distributor: Distributor, clientSecret: String, sessionId: SessionId): Result { UnifiedPush.saveDistributor(context, distributor.value) // This will trigger the callback // VectorUnifiedPushMessagingReceiver.onNewEndpoint - UnifiedPush.register(context = context, instance = clientSecret) + UnifiedPush.register(context = context, instance = clientSecret, messageForDistributor = sessionId.value) // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed @Suppress("RunCatchingNotAllowed") return runCatching { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt index 7288fccab2..f72a591faa 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -36,7 +36,7 @@ class UnifiedPushProvider( override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - return registerUnifiedPushUseCase.execute(distributor, clientSecret) + return registerUnifiedPushUseCase.execute(distributor, clientSecret, matrixClient.sessionId) .onSuccess { unifiedPushStore.setDistributorValue(matrixClient.sessionId, distributor.value) } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt index fe7da75951..5460005cef 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt @@ -12,6 +12,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult @@ -41,7 +42,7 @@ class DefaultRegisterUnifiedPushUseCaseTest : RobolectricTest() { delay(100) endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.success(Unit))) } - val result = useCase.execute(aDistributor, A_SECRET) + val result = useCase.execute(aDistributor, A_SECRET, A_SESSION_ID) assertThat(result.isSuccess).isTrue() } @@ -56,7 +57,7 @@ class DefaultRegisterUnifiedPushUseCaseTest : RobolectricTest() { delay(100) endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.failure(AN_EXCEPTION))) } - val result = useCase.execute(aDistributor, A_SECRET) + val result = useCase.execute(aDistributor, A_SECRET, A_SESSION_ID) assertThat(result.isSuccess).isFalse() } @@ -67,7 +68,7 @@ class DefaultRegisterUnifiedPushUseCaseTest : RobolectricTest() { endpointRegistrationHandler = endpointRegistrationHandler ) val aDistributor = Distributor("aValue", "aName") - val result = useCase.execute(aDistributor, A_SECRET) + val result = useCase.execute(aDistributor, A_SECRET, A_SESSION_ID) assertThat(result.isSuccess).isFalse() } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt index 3055dad6b7..55729f29f8 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt @@ -8,13 +8,14 @@ package io.element.android.libraries.pushproviders.unifiedpush +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.tests.testutils.lambda.lambdaError class FakeRegisterUnifiedPushUseCase( - private val result: (Distributor, String) -> Result = { _, _ -> lambdaError() } + private val result: (Distributor, String, SessionId) -> Result = { _, _, _ -> lambdaError() } ) : RegisterUnifiedPushUseCase { - override suspend fun execute(distributor: Distributor, clientSecret: String): Result { - return result(distributor, clientSecret) + override suspend fun execute(distributor: Distributor, clientSecret: String, sessionId: SessionId): Result { + return result(distributor, clientSecret, sessionId) } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt index e22c1c3bf4..e766068783 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt @@ -62,7 +62,7 @@ class UnifiedPushProviderTest { @Test fun `register ok`() = runTest { val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } - val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val executeLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } val setDistributorValueResultLambda = lambdaRecorder { _, _ -> } val unifiedPushProvider = createUnifiedPushProvider( pushClientSecret = FakePushClientSecret( @@ -82,7 +82,7 @@ class UnifiedPushProviderTest { .with(value(A_SESSION_ID)) executeLambda.assertions() .isCalledOnce() - .with(value(Distributor("value", "Name")), value(A_SECRET)) + .with(value(Distributor("value", "Name")), value(A_SECRET), value(A_SESSION_ID)) setDistributorValueResultLambda.assertions() .isCalledOnce() .with(value(A_SESSION_ID), value("value")) @@ -91,7 +91,7 @@ class UnifiedPushProviderTest { @Test fun `register ko`() = runTest { val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } - val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val executeLambda = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } val setDistributorValueResultLambda = lambdaRecorder(ensureNeverCalled = true) { _, _ -> } val unifiedPushProvider = createUnifiedPushProvider( pushClientSecret = FakePushClientSecret( @@ -111,7 +111,7 @@ class UnifiedPushProviderTest { .with(value(A_SESSION_ID)) executeLambda.assertions() .isCalledOnce() - .with(value(Distributor("value", "Name")), value(A_SECRET)) + .with(value(Distributor("value", "Name")), value(A_SECRET), value(A_SESSION_ID)) } @Test diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt index 8a1b2f9f21..cec03e3c78 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -13,15 +13,36 @@ import kotlinx.coroutines.flow.Flow * Store data related to push about a user. */ interface UserPushStore { + /** The push provider selected for this user, or `null` when none has been selected yet. */ suspend fun getPushProviderName(): String? + + /** + * @param value the name of the push provider to remember for this user. + */ suspend fun setPushProviderName(value: String) + + /** The push key last registered with the homeserver, used to detect that a re-registration is needed; `null` when never registered. */ suspend fun getCurrentRegisteredPushKey(): String? + + /** + * @param value the push key that has just been registered, or `null` to forget it after unregistering. + */ suspend fun setCurrentRegisteredPushKey(value: String?) + /** Whether the user has enabled notifications on this device. */ fun getNotificationEnabledForDevice(): Flow + + /** + * @param enabled true to enable notifications on this device. + */ suspend fun setNotificationEnabledForDevice(enabled: Boolean) + /** Whether the user has asked not to be warned about pusher registration failures. */ fun ignoreRegistrationError(): Flow + + /** + * @param ignore true to stop warning the user about registration failures. + */ suspend fun setIgnoreRegistrationError(ignore: Boolean) /** @@ -29,5 +50,6 @@ interface UserPushStore { */ fun useCompleteNotificationFormat(): Boolean + /** Erases every push preference of this user, to be called when their session is removed. */ suspend fun reset() } diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt index 78571d8b30..5210602e6c 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt @@ -14,5 +14,10 @@ import io.element.android.libraries.matrix.api.core.SessionId * Store data related to push about a user. */ interface UserPushStoreFactory { + /** + * Returns the push store of a user, creating it on first use. + * + * @param userId the user whose store is requested. + */ fun getOrCreate(userId: SessionId): UserPushStore } diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt index da3ea946f0..a2f4c0ce3b 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt @@ -10,15 +10,24 @@ package io.element.android.libraries.pushstore.api.clientsecret import io.element.android.libraries.matrix.api.core.SessionId +/** + * Manages the per-session client secret sent along with a pusher registration. + * + * Push gateways deliver notifications without saying which account they belong to, so the secret is what lets an incoming push be attributed to a session. + */ interface PushClientSecret { /** * To call when registering a pusher. It will return the existing secret or create a new one. + * + * @param userId the user the pusher is being registered for. */ suspend fun getSecretForUser(userId: SessionId): String /** * To call when receiving a push containing a client secret. * Return null if not found. + * + * @param clientSecret the secret carried by the incoming push. */ suspend fun getUserIdFromSecret(clientSecret: String): SessionId? } diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt index d7ea7d5239..0b425b0115 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt @@ -8,6 +8,10 @@ package io.element.android.libraries.pushstore.api.clientsecret +/** + * Generates the client secrets used to attribute an incoming push to a session; see [PushClientSecret] for how they are used. + */ interface PushClientSecretFactory { + /** Returns a new random secret. */ fun create(): String } diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt index ecb87b2b14..c325042ff8 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt @@ -10,9 +10,37 @@ package io.element.android.libraries.pushstore.api.clientsecret import io.element.android.libraries.matrix.api.core.SessionId +/** + * Persists the mapping between sessions and their push client secrets, in both directions. + */ interface PushClientSecretStore { + /** + * Stores the secret of a user, replacing any previous one. + * + * @param userId the user the secret belongs to. + * @param clientSecret the secret to store. + */ suspend fun storeSecret(userId: SessionId, clientSecret: String) + + /** + * Returns the stored secret of a user, or `null` when they have none yet. + * + * @param userId the user whose secret is requested. + */ suspend fun getSecret(userId: SessionId): String? + + /** + * Forgets the secret of a user, to be called when their session is removed. + * + * @param userId the user whose secret is erased. + */ suspend fun resetSecret(userId: SessionId) + + /** + * Reverse lookup used when a push arrives carrying only a secret. + * + * @param clientSecret the secret to resolve. + * @return the user it belongs to, or `null` when no session matches. + */ suspend fun getUserIdFromSecret(clientSecret: String): SessionId? } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt index e57266b7e7..c7fa907be2 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode @@ -79,5 +80,13 @@ internal fun aRoomSelectRoomList() = persistentListOf( ), aSelectRoomInfo( roomId = RoomId("!room3:domain"), + name = "Alice", + heroes = persistentListOf( + aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + ), + isDm = true, + ), + aSelectRoomInfo( + roomId = RoomId("!room4:domain"), ), ) diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 7153306746..357d86dd5a 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -15,11 +15,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -144,7 +149,13 @@ fun RoomSelectView( .consumeWindowInsets(paddingValues) ) { SearchBar( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding( + WindowInsets.safeDrawing + .only(WindowInsetsSides.Horizontal) + .asPaddingValues() + ), placeHolderTitle = stringResource(CommonStrings.action_search), queryState = state.searchQuery, active = state.isSearchActive, @@ -184,7 +195,10 @@ fun RoomSelectView( showVerticalSpace = true, ) if (state.resultState is SearchBarResultState.Results) { - LazyColumn(state = lazyListState) { + LazyColumn( + state = lazyListState, + contentPadding = lazyColumnContentPadding, + ) { items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> Column { RoomSummaryView( @@ -260,10 +274,11 @@ private fun RoomSummaryView( maxLines = 1, overflow = TextOverflow.Ellipsis ) - // Alias - roomInfo.canonicalAlias?.let { alias -> + val otherUserId = roomInfo.heroes.singleOrNull()?.userId?.takeIf { roomInfo.isDm } + val subtitle = roomInfo.canonicalAlias?.value ?: otherUserId?.value + if (subtitle != null) { Text( - text = alias.value, + text = subtitle, color = ElementTheme.colors.textSecondary, style = ElementTheme.typography.fontBodySmRegular, maxLines = 1, diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt new file mode 100644 index 0000000000..db0219bb04 --- /dev/null +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectViewTest.kt @@ -0,0 +1,89 @@ +/* + * 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(ExperimentalTestApi::class) + +package io.element.android.libraries.roomselect.impl + +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.onNodeWithText +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.collections.immutable.persistentListOf +import org.junit.Test +import org.robolectric.annotation.Config + +class RoomSelectViewTest : RobolectricTest() { + @Config(qualifiers = "h1024dp") + @Test + fun `a room with a canonical alias renders the alias`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + ), + ) + onNodeWithText("Room with alias").assertIsDisplayed() + onNodeWithText("#alias:example.org").assertIsDisplayed() + } + + @Config(qualifiers = "h1024dp") + @Test + fun `a direct message without alias renders the matrix id of the other user`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + ), + ) + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("@alice:example.org").assertIsDisplayed() + } + + @Config(qualifiers = "h1024dp") + @Test + fun `a room which is not a direct message does not render the matrix id of its hero`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results( + persistentListOf( + aSelectRoomInfo( + roomId = RoomId("!room:domain"), + name = "Room with a single hero", + heroes = persistentListOf( + aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + ), + ), + ) + ), + ), + ) + onNodeWithText("Room with a single hero").assertIsDisplayed() + onNodeWithText("@alice:example.org").assertDoesNotExist() + } +} + +private fun AndroidComposeUiTest.setRoomSelectView( + state: RoomSelectState, + onDismiss: () -> Unit = EnsureNeverCalled(), + onSubmit: (List) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + RoomSelectView( + state = state, + onDismiss = onDismiss, + onSubmit = onSubmit, + ) + } +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index fb60311418..641a2afdf2 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -11,6 +11,11 @@ package io.element.android.libraries.sessionstorage.api import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +/** + * Persists the logged in sessions and their access tokens, so the app can restore them after a restart. + * + * Sessions are ordered by last usage, which is what makes "the latest session" meaningful across the interface. + */ interface SessionStore { /** * A flow emitting the current logged in state. @@ -27,22 +32,32 @@ interface SessionStore { /** * Add a new session. If other sessions exist, the new one will be set as the latest used one, and * the added session position will be set to a value higher than the other session positions. + * + * @param sessionData the session to store, including its tokens. */ suspend fun addSession(sessionData: SessionData) /** * Will update the session data matching the userId, except the value of loginTimestamp. * No op if userId is not found in DB. + * + * @param sessionData the new content of the session. */ suspend fun updateData(sessionData: SessionData) /** * Update the user profile info of the session matching the userId. + * + * @param sessionId the session to update. + * @param displayName the new display name, or `null` when the user has none. + * @param avatarUrl the new avatar, or `null` when the user has none. */ suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) /** * Get the session data matching the userId, or null if not found. + * + * @param sessionId the session to read. */ suspend fun getSession(sessionId: String): SessionData? @@ -63,11 +78,15 @@ interface SessionStore { /** * Set the session with [sessionId] as the latest used one. + * + * @param sessionId the session to bring to the front of the ordering. */ suspend fun setLatestSession(sessionId: String) /** * Remove the session matching the sessionId. + * + * @param sessionId the session to forget, which also discards its tokens. */ suspend fun removeSession(sessionId: String) } diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt index 8dadcafc7d..8d94af5b13 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionListener.kt @@ -8,7 +8,22 @@ package io.element.android.libraries.sessionstorage.api.observer +/** + * Reacts to sessions being created or deleted; both methods have an empty default so a listener only overrides what it cares about. + */ interface SessionListener { + /** + * Called after a session has been stored, for instance following a login. + * + * @param userId the session that was created. + */ suspend fun onSessionCreated(userId: String) {} + + /** + * Called after a session has been removed, which is where per-session data should be cleaned up. + * + * @param userId the session that was deleted. + * @param wasLastSession true when no session remains, so app-wide data can be cleaned up too. + */ suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {} } diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt index fb120acd56..08925ee688 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/observer/SessionObserver.kt @@ -8,7 +8,17 @@ package io.element.android.libraries.sessionstorage.api.observer +/** + * Notifies interested components when a session is created or deleted, so they can set up or clean up their own per-session data. + */ interface SessionObserver { + /** + * @param listener the listener to notify from now on. + */ fun addListener(listener: SessionListener) + + /** + * @param listener the listener to stop notifying. + */ fun removeListener(listener: SessionListener) } diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt index 9dfca26078..7ae056e38b 100644 --- a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt +++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt @@ -9,7 +9,19 @@ package io.element.android.libraries.slashcommands.api import io.element.android.libraries.matrix.api.timeline.Timeline +/** + * Recognises and runs the slash commands the user can type in the composer, such as `/me` or `/join`. + * + * The composer first [parse]s what was typed, then hands the result to whichever proceed method matches its kind; navigation + * commands and the error cases are handled by the composer itself. + */ interface SlashCommandService { + /** + * Returns the commands whose name starts with what has been typed, so the composer can offer an autocomplete list. + * + * @param text the current composer content. + * @param isInThread whether the composer is in a thread, since not every command is available there. + */ suspend fun getSuggestions( text: String, isInThread: Boolean, @@ -17,6 +29,11 @@ interface SlashCommandService { /** * Parse the message and return a SlashCommand. + * Ordinary text comes back as `NotACommand`, and a malformed or unsupported command as one of the error cases, so this never throws. + * + * @param textMessage the composer content as plain text. + * @param formattedMessage the composer content as HTML, or `null` when unformatted. + * @param isInThreadTimeline whether the composer is in a thread. */ suspend fun parse( textMessage: CharSequence, @@ -26,6 +43,9 @@ interface SlashCommandService { /** * Proceed a SlashCommandSendMessage. + * + * @param slashCommand the parsed command, carrying the content to send. + * @param timeline the timeline to send it to. */ suspend fun proceedSendMessage( slashCommand: SlashCommand.SlashCommandSendMessage, @@ -33,7 +53,9 @@ interface SlashCommandService { ): Result /** - * Proceed a SlashCommandAdmin. + * Proceed a SlashCommandAdmin, i.e. one that acts on the room or its members rather than sending a message. + * + * @param slashCommand the parsed command to run. */ suspend fun proceedAdmin( slashCommand: SlashCommand.SlashCommandAdmin, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 0f62eb5234..66c9dcba03 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -131,6 +131,7 @@ fun TextComposer( resolveAtRoomMentionDisplay: () -> TextDisplay, modifier: Modifier = Modifier, showTextFormatting: Boolean = false, + isInThreadTimeline: Boolean = false, ) { val markdown = when (state) { is TextEditorState.Markdown -> state.state.text.value() @@ -153,13 +154,15 @@ fun TextComposer( .fillMaxSize() .height(IntrinsicSize.Min) - val placeholder = if (composerMode.inThread) { + val placeholder = if (composerMode.inThread || composerMode is MessageComposerMode.Normal && isInThreadTimeline) { stringResource(id = CommonStrings.action_reply_in_thread) } else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) { stringResource(id = R.string.rich_text_editor_composer_caption_placeholder) } else { stringResource(id = R.string.rich_text_editor_composer_placeholder) } + val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment + val textInput: @Composable () -> Unit = when (state) { is TextEditorState.Rich -> { val coroutineScope = rememberCoroutineScope() @@ -215,6 +218,7 @@ fun TextComposer( placeholder = placeholder, placeholderColor = ElementTheme.colors.textSecondary, onTyping = onTyping, + onSendMessage = { if (canSendTextMessage) onSendMessage() }, onReceiveSuggestion = onReceiveSuggestion, richTextEditorStyle = style, onSelectRichContent = onSelectRichContent, @@ -224,8 +228,6 @@ fun TextComposer( } } - val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment - val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { @Composable { TextFormatting(state = it.richTextEditorState) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt index 3ee191d724..9932dbaea1 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt @@ -179,7 +179,7 @@ private fun EditLinkDialog( } item { ListItem( - headlineContent = { + content = { Text( text = stringResource(R.string.rich_text_editor_remove_link), color = ElementTheme.colors.textCriticalPrimary diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt index bd808c9acb..8cc06c77c3 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -9,6 +9,8 @@ package io.element.android.libraries.textcomposer.components.markdown import android.content.Context +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.view.View import androidx.appcompat.widget.AppCompatEditText @@ -17,8 +19,27 @@ internal class MarkdownEditText( ) : AppCompatEditText(context) { var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + var onEnterKeyListener: (() -> Boolean)? = null + private var isModifyingText = false + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.keyCode == KeyEvent.KEYCODE_ENTER && + !event.isShiftPressed && + event.deviceId != KeyCharacterMap.VIRTUAL_KEYBOARD + ) { + val listener = onEnterKeyListener + if (listener != null) { + return when (event.action) { + KeyEvent.ACTION_UP -> true + KeyEvent.ACTION_DOWN -> if (event.repeatCount == 0) listener() else true + else -> super.dispatchKeyEvent(event) + } + } + } + return super.dispatchKeyEvent(event) + } + fun updateEditableText(charSequence: CharSequence) { isModifyingText = true editableText.clear() diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index 064ef03106..63f9e0a8ea 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -51,6 +51,7 @@ fun MarkdownTextInput( placeholder: String, placeholderColor: androidx.compose.ui.graphics.Color, onTyping: (Boolean) -> Unit, + onSendMessage: () -> Unit, onReceiveSuggestion: (Suggestion?) -> Unit, richTextEditorStyle: RichTextEditorStyle, onSelectRichContent: ((Uri) -> Unit)?, @@ -132,6 +133,10 @@ fun MarkdownTextInput( update = { editText -> editText.contentDescription = placeholder editText.applyStyleInCompose(richTextEditorStyle) + editText.onEnterKeyListener = { + onSendMessage() + true + } val text = state.text.value() mentionSpanUpdater.updateMentionSpans(text) if (state.text.needsDisplaying()) { @@ -210,6 +215,7 @@ internal fun MarkdownTextInputPreview() { placeholder = "Placeholder", placeholderColor = ElementTheme.colors.textSecondary, onTyping = {}, + onSendMessage = {}, onReceiveSuggestion = {}, richTextEditorStyle = style, onSelectRichContent = {}, diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index ba49428cae..d08048241e 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -10,6 +10,8 @@ package io.element.android.libraries.textcomposer.impl.components.markdown +import android.view.KeyCharacterMap +import android.view.KeyEvent import android.widget.EditText import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest @@ -32,12 +34,51 @@ import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType import io.element.android.libraries.textcomposer.model.aMarkdownTextEditorState +import io.element.android.tests.testutils.EnsureCalledOnce import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test class MarkdownTextInputTest : RobolectricTest() { + @Test + fun `pressing enter on a physical keyboard sends the message`() = runAndroidComposeUiTest { + val state = aMarkdownTextEditorState(initialText = "Hello", initialFocus = true) + val onSendMessage = EnsureCalledOnce() + setMarkdownTextInput(state = state, onSendMessage = onSendMessage) + val editor = activity!!.findEditor() + editor.pressEnter() + awaitIdle() + onSendMessage.assertSuccess() + assertThat(editor.editableText.toString()).isEqualTo("Hello") + } + + @Test + fun `pressing shift enter on a physical keyboard inserts a new line`() = runAndroidComposeUiTest { + val state = aMarkdownTextEditorState(initialText = "Hello", initialFocus = true) + var sendCount = 0 + setMarkdownTextInput(state = state, onSendMessage = { sendCount++ }) + val editor = activity!!.findEditor() + editor.setSelection(editor.editableText.length) + editor.pressEnter(metaState = KeyEvent.META_SHIFT_ON) + awaitIdle() + assertThat(sendCount).isEqualTo(0) + assertThat(editor.editableText.toString()).isEqualTo("Hello\n") + } + + @Test + fun `pressing enter on the soft keyboard does not send the message`() = runAndroidComposeUiTest { + val state = aMarkdownTextEditorState(initialText = "Hello", initialFocus = true) + var sendCount = 0 + setMarkdownTextInput(state = state, onSendMessage = { sendCount++ }) + val editor = activity!!.findEditor() + editor.setSelection(editor.editableText.length) + editor.pressEnter(deviceId = KeyCharacterMap.VIRTUAL_KEYBOARD) + awaitIdle() + assertThat(sendCount).isEqualTo(0) + assertThat(editor.editableText.toString()).isEqualTo("Hello\n") + } + @Test fun `when user types onTyping is triggered with value 'true'`() = runAndroidComposeUiTest { val state = aMarkdownTextEditorState(initialFocus = true) @@ -150,6 +191,7 @@ class MarkdownTextInputTest : RobolectricTest() { state: MarkdownTextEditorState = aMarkdownTextEditorState(), onTyping: (Boolean) -> Unit = {}, onSuggestionReceived: (Suggestion?) -> Unit = {}, + onSendMessage: (() -> Unit) = {}, ) { setContent { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) @@ -161,6 +203,27 @@ class MarkdownTextInputTest : RobolectricTest() { onReceiveSuggestion = onSuggestionReceived, richTextEditorStyle = style, onSelectRichContent = null, + onSendMessage = onSendMessage, + ) + } + } + + private fun EditText.pressEnter( + metaState: Int = 0, + deviceId: Int = PHYSICAL_KEYBOARD_DEVICE_ID, + ) { + listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP).forEach { action -> + dispatchKeyEvent( + KeyEvent( + 0L, + 0L, + action, + KeyEvent.KEYCODE_ENTER, + 0, + metaState, + deviceId, + 0, + ) ) } } @@ -168,4 +231,8 @@ class MarkdownTextInputTest : RobolectricTest() { private fun ComponentActivity.findEditor(): EditText { return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) } + + private companion object { + const val PHYSICAL_KEYBOARD_DEVICE_ID = 1 + } } diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt index a4be22e70b..69a0561d25 100644 --- a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootNavigator.kt @@ -8,6 +8,10 @@ package io.element.android.libraries.troubleshoot.api.test +/** + * Lets a troubleshooting test's quick fix send the user to another screen, without the test itself knowing about navigation. + */ interface NotificationTroubleshootNavigator { + /** Opens the blocked users screen, so the user can unblock someone whose messages were being filtered out. */ fun navigateToBlockedUsers() } diff --git a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt index 27f3bcf8ed..98898ad483 100644 --- a/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt +++ b/libraries/troubleshoot/api/src/main/kotlin/io/element/android/libraries/troubleshoot/api/test/NotificationTroubleshootTest.kt @@ -20,11 +20,36 @@ import kotlinx.coroutines.flow.StateFlow * the component they're injected into is bound to [SessionScope] and so should these (https://github.com/ZacSweers/metro/issues/1932). */ interface NotificationTroubleshootTest { + /** Position of this test in the list shown to the user; tests run in ascending order. */ val order: Int + + /** The current state of this test, which the UI observes to show it as idle, running, successful or failed. */ val state: StateFlow + + /** + * Whether this test applies to the current setup, so that irrelevant tests are hidden rather than shown as failing. + * + * @param data the current push provider and distributor, used to decide relevance. + */ fun isRelevant(data: TestFilterData): Boolean = true + + /** + * Runs the test, reporting its outcome through [state] rather than by returning or throwing. + * + * @param coroutineScope the scope to launch any work that must outlive the call itself. + */ suspend fun run(coroutineScope: CoroutineScope) + + /** Returns the test to its idle state so it can be run again. */ suspend fun reset() + + /** + * Attempts to fix what this test detected, for the tests that can offer it. Throws by default, so check the state's quick fix + * flag before calling. + * + * @param coroutineScope the scope to launch any work that must outlive the call itself. + * @param navigator used by the fixes that have to send the user to another screen. + */ suspend fun quickFix( coroutineScope: CoroutineScope, navigator: NotificationTroubleshootNavigator, diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt index fa5e7078d2..4e0f89155d 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt @@ -68,7 +68,7 @@ private fun ColumnScope.TroubleshootTestView( val status = testState.status if ((status as? Status.Idle)?.visible == false) return ListItem( - headlineContent = { Text(text = testState.name) }, + content = { Text(text = testState.name) }, supportingContent = { Text(text = testState.description) }, trailingContent = when (status) { is Status.Idle -> null @@ -108,7 +108,7 @@ private fun ColumnScope.TroubleshootTestView( ) if (status is Status.Failure && status.hasQuickFix) { ListItem( - headlineContent = { }, + content = { }, trailingContent = ListItemContent.Custom { Button( text = status.quickFixButtonString ?: stringResource(id = R.string.troubleshoot_notifications_screen_quick_fix_action), @@ -137,7 +137,7 @@ private fun ColumnScope.TroubleshootNotificationsContent(state: TroubleshootNoti } when (state.testSuiteState.mainState) { AsyncAction.Uninitialized -> { - ListItem(headlineContent = { + ListItem(content = { Text( text = stringResource(id = R.string.troubleshoot_notifications_screen_notice) ) @@ -146,20 +146,20 @@ private fun ColumnScope.TroubleshootNotificationsContent(state: TroubleshootNoti } AsyncAction.Loading -> Unit is AsyncAction.Failure -> { - ListItem(headlineContent = { + ListItem(content = { Text(text = stringResource(id = R.string.troubleshoot_notifications_screen_failure)) }) RunTestButton(state = state) } is AsyncAction.Confirming -> { - ListItem(headlineContent = { + ListItem(content = { Text( text = stringResource(id = R.string.troubleshoot_notifications_screen_waiting) ) }) } is AsyncAction.Success -> { - ListItem(headlineContent = { + ListItem(content = { Text( text = stringResource(id = R.string.troubleshoot_notifications_screen_success) ) @@ -171,7 +171,7 @@ private fun ColumnScope.TroubleshootNotificationsContent(state: TroubleshootNoti @Composable private fun RunTestButton(state: TroubleshootNotificationsState) { ListItem( - headlineContent = { + content = { Button( text = stringResource( id = if (state.testSuiteState.mainState is AsyncAction.Failure) { diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt index 7dfb8419dd..21e9c64139 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryView.kt @@ -156,7 +156,7 @@ private fun PushHistoryContent( modifier = modifier.fillMaxWidth() ) { ListItem( - headlineContent = { Text("Total number of received push") }, + content = { Text("Total number of received push") }, trailingContent = ListItemContent.Text(state.pushCounter.toString()), ) LazyColumn( diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index d83fb1ee64..bcc897751a 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -35,12 +35,12 @@ "QR-Code" "Reagiere mit %1$s" "Mit anderen Emojis reagieren" - "Gelesen von %1$s und %2$s" + "Vorherige Nachricht gelesen von %1$s und %2$s" - "Gelesen von %1$s und %2$d weiteren" - "Gelesen von %1$s und %2$d weiteren" + "Vorherige Nachricht gelesen von %1$s und %2$d weiteren" + "Vorherige Nachricht gelesen von %1$s und %2$d weiteren" - "Gelesen von %1$s" + "Vorherige Nachricht gelesen von %1$s" "Reaktion mit %1$s entfernen" "Entferne Reaktionen mit %1$s" "Avatar" @@ -49,6 +49,7 @@ "Zeitlich begrenzte Handlung erforderlich, du hast eine Minute Zeit zur Verifizierung" "Passwort anzeigen" "Anruf starten" + "Starte einen Videoanruf" "Sprachanruf starten" "Stillgelegter Chat" "Nutzer-Avatar" @@ -460,6 +461,13 @@ Möchtest du wirklich fortfahren?" "Optionen" "Entferne %1$s" "Einstellungen" + "Niemand teilt seinen Standort" + "Live-Standort teilen" + + "%1$d Person" + "%1$d Leute" + + "Auf der Karte" "Medienauswahl fehlgeschlagen, bitte versuche es erneut." "Halte eine Nachricht gedrückt und wähle “%1$s”, um sie hier einzufügen." "Fixiere wichtige Nachrichten, so dass sie leicht gefunden werden können" @@ -484,6 +492,7 @@ Möchtest du wirklich fortfahren?" "Nachricht in %1$s" "Erweitern" "Verkleinern" + "Live-Standort teilen" "Du siehst diesen Chat bereits!" "%1$s von %2$s" "%1$s fixierte Nachrichten" diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index c24e8652f6..14800b9ca3 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -507,6 +507,14 @@ Kas sa oled kindel, et soovid jätkata?" "%1$s Android" "Veast teatamiseks raputa nutiseadet ägedalt" "Ekraanitõmmis" + "Logi sisse" + "Loo kasutajakonto" + "Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)." + "Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)." + "Teenusepakkuja" + "domeeninäidis.ee" + "Sisesta oma kasutajakonto teenusepakkuja" + "Vali kasutajakonto teenusepakkuja" "%1$s: %2$s" "Valikud" "Kustuta: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-hr/translations.xml b/libraries/ui-strings/src/main/res/values-hr/translations.xml index 3c02673a22..c0879d3a2e 100644 --- a/libraries/ui-strings/src/main/res/values-hr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml @@ -8,6 +8,7 @@ "Minimiziraj tekstno polje poruke" "Smanjenje dopuštenih glasova po osobi" "Povećanje broja dopuštenih glasova po osobi" + "Prilagođeni Emoji" "Izbriši" "%1$d unesena znamenka" @@ -65,6 +66,7 @@ "Započni poziv" "Započni videopoziv" "Započni glasovni poziv" + "Stanje" "Nit u %1$s" "Teme u %1$s" "Soba označena za uklanjanje" diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml index 0a37baf199..f198cc0510 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -8,6 +8,7 @@ "Üzenet szövegmezőjének minimalizálása" "Személyenként engedélyezett szavazatok számának csökkentése" "Személyenként engedélyezett szavazatok számának növelése" + "Egyéni emoji" "Törlés" "%1$d megadott számjegy" @@ -23,6 +24,7 @@ "Csatlakozás a híváshoz" "Ugrás az aljára" "Ugrás az olvasatlanhoz" + "Ugrás az első olvasatlan üzenetre" "Térkép áthelyezése a jelenlegi helyre" "Csak megemlítések" "Némítva" @@ -63,6 +65,7 @@ "Hanghívás indítása" "Videohívás indítása" "Hanghívás indítása" + "Állapot" "Üzenetszál itt: %1$s" "Üzenetszálak itt: %1$s" "Elévült szoba" @@ -143,6 +146,7 @@ "Fiók és eszközök kezelése" "Eszközök kezelése" "Szobák kezelése" + "Megjelölés olvasottként" "Üzenet" "Minimalizálás" "Következő" @@ -267,6 +271,14 @@ Ok: %1$s." "Üzenet továbbítása" "Gyakran használt" "Galéria" + + "%1$d melléklet…" + "%1$d melléklet…" + + + "%1$d média…" + "%1$d média…" + "GIF" "Csoportos hívás folyamatban" "Kép" @@ -304,6 +316,7 @@ Ok: %1$s." "Nincs térnév" "Nincs titkosítva" "Kapcsolat nélkül" + "Hívásban" "Nyílt forráskódú licencek" "vagy" "További lehetőségek" @@ -505,6 +518,8 @@ Biztos, hogy folytatja?" "%1$d személy" "A térképen" + "A Matrix-kiszolgáló helyi hálózaton található. A csatlakozáshoz engedélyre van szüksége ennek: %1$s , hogy elérje a hálózaton lévő eszközöket." + "Hozzáférés engedélyezése a helyi hálózathoz" "Nem sikerült kiválasztani a médiát, próbálja újra." "Nyomjon hosszan az üzenetre, és válassza a „%1$s” lehetőséget, hogy itt szerepeljen." "Tűzze ki a fontos üzeneteket, hogy könnyen felfedezhetők legyenek" diff --git a/libraries/ui-strings/src/main/res/values-pl/translations.xml b/libraries/ui-strings/src/main/res/values-pl/translations.xml index 0234af883a..1ee4275703 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -20,6 +20,16 @@ "Podgląd pełnego adresu %1$s" "Szczegóły szyfrowania" "Powiększ pole tekstowe wiadomości" + "Galeria, załącznik %1$d" + "Galeria, nieudane skanowanie %1$d" + "Galeria, obraz %1$d" + + "Galeria, %1$d dodatkowe media" + "Galeria, %1$d dodatkowe media" + "Galeria, %1$d dodatkowych mediów" + + "Galeria, skanowanie %1$d" + "Galeria, wideo %1$d" "Ukryj hasło" "Informacje" "Dołącz do połączenia" @@ -184,8 +194,8 @@ "Wyślij edytowaną wiadomość" "Wyślij wiadomość" "Wyślij wiadomość głosową" - "Wyślij" - "Wyślij link" + "Udostępnij" + "Udostępnij link" "Udostępnij lokalizację na żywo" "Pokaż" "Zaloguj się ponownie" @@ -518,6 +528,14 @@ Czy na pewno chcesz kontynuować?" "%1$s Android" "Wstrząśnij gniewnie, aby zgłosić błąd" "Zrzut ekranu" + "Zaloguj" + "Utwórz konto" + "Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)" + "Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)." + "Dostawca konta" + "przykład.com" + "Wprowadź dostawcę swojego konta" + "Wybierz dostawcę konta" "%1$s: %2$s" "Opcje" "Usuń %1$s" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 4eba83312b..71b5ab7041 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -1,23 +1,41 @@ + "Adăugați un avatar" "Adăugați o reacție: %1$s" "Adresă" "Imagine de profil" + "Ștergeți câmpul de căutare" "Micșorați câmpul mesajului" + "Reduceți numărul de voturi permise per persoană" + "Măriți numărul de voturi permise per persoană" + "Emoji personalizat" "Ștergere" "%1$d cifră introdusă" "%1$d cifre introduse" "%1$d cifre introduse" + "Durată: %1$s" "Editați avatarul" "Adresa completă va fi %1$s" "Detalii privind criptarea" "Extindeți câmpul mesajului" + "Galerie, atașament %1$d" + "Galerie, scanare eșuată %1$d" + "Galerie, imagine %1$d" + + "Galerie, încă %1$d fișier media" + "Galerie, încă %1$d fișiere media" + "Galerie, încă %1$d fișiere media" + + "Galerie, scanare %1$d" + "Galerie, videoclip %1$d" "Ascundeți parola" "Informații" "Alăturați-vă apelului" "Mergeți în jos" + "Săriți la necitite" + "Săriți la primul mesaj necitit" "Mutați harta la locația mea" "Doar mențiuni" "Notificări dezactivate" @@ -28,12 +46,14 @@ "Pagina %1$d" "Pauză" "Mesaj vocal, durată:%1$s, poziție curentă: %2$s" + "Previzualizare foto" "Câmp PIN" "Locație fixată" "Redați" "Viteză de redare" "Sondaj" "Sondaj încheiat" + "Poziție: %1$s" "Cod QR" "Reacționați cu %1$s" "Reacționați cu alte emoji-uri" @@ -44,20 +64,26 @@ "Citit de %1$s și incă %2$d" "Citit de %1$s" + "afișați toate confirmările de citire" "Îndepărtați reacția cu %1$s" "Îndepărtați reacția %1$s" "Avatarul camerei" "Trimiteți fișiere" "Locația expeditorului" + "Trimis de %1$s la %2$s" "Acțiune limitată în timp necesară, aveți un minut pentru a verifica" "Setări, acțiune necesară" "Afișați parola" "Începeți un apel" "Inițiați un apel video" "Inițiați un apel vocal" + "Statuts" + "Fir în: %1$s" + "Fire în %1$s" "Cameră terminată" "Avatar utilizator" "Meniu utilizator" + "Previzualizare video" "Vizualizați avatarul" "Vizualizați detalii" "Mesaj vocal, durată: %1$s" @@ -94,6 +120,7 @@ "Refuzați și blocați" "Ștergere" "Ștergeți contul" + "Ștergeți fișierul" "Ștergeți sondajul" "Deselectați tot" "Dezactivați" @@ -131,6 +158,7 @@ "Gestionați contul și dispozitivele" "Gestionați dispozitivele" "Gestionați camerele" + "Marcați ca citit" "Contactați" "Minimizați" "Următorul" @@ -185,9 +213,11 @@ "Traduceți" "Încercați din nou" "Defixați" + "Apel video" "Vizualizați" "Vedeți în lista de mesaje" "Vedeți sursă" + "Apel vocal" "Da" "Da, încercați din nou" "Serverul dvs. acceptă acum un protocol nou, mai rapid. Deconectați-vă și conectați-vă din nou pentru a face upgrade acum. Dacă faceți acest lucru acum, vă va ajuta să evitați o deconectare forțată atunci când vechiul protocol este eliminat ulterior." @@ -208,6 +238,7 @@ "Utilizatori blocați" "Baloane" "Apel respins" + "Apel în curs" "Apel inițiat" "Ați respins un apel" "Backup conversații" @@ -251,7 +282,19 @@ Motiv:%1$s." "Fișier salvat în Descărcări" "Redirecționați mesajul" "Utilizate frecvent" + "Galerie" + + "%1$d atașament…" + "%1$d atașamente…" + "%1$d atașamente…" + + + "%1$d element media…" + "%1$d elemente media…" + "%1$d elemente media…" + "GIF" + "Apel de grup în curs" "Imagine" "Ca răspuns la %1$s" "Instalați APK" @@ -289,6 +332,7 @@ Motiv:%1$s." "Fără nume de spațiu" "Necriptat" "Deconectat" + "Într-un apel" "Licențe open source" "sau" "Alte opțiuni" @@ -301,6 +345,7 @@ Motiv:%1$s." "Va rugam asteptati…" "Sunteți sigur că doriți să încheiați acest sondaj?" "Sondajul %1$s" + "Sondaj" "Total voturi: %1$s" "Rezultatele vor fi afișate după încheierea sondajului" @@ -400,6 +445,7 @@ Motiv:%1$s." "Activare sunet" "Apel incompatibil" "Eveniment neacceptat" + "%1$s a inițiat un apel" "Utilizator" "Verificare anulată" "Verificare completă" @@ -421,6 +467,11 @@ Motiv:%1$s." "Se așteptă localizarea în timp real…" "Oricine poate vedea istoricul" "Dumneavoastră" + "Descărcarea nu este disponibilă." + "Ceva nu a mers bine" + "Se scanează…" + "Previzualizarea și descărcarea au fost dezactivate." + "Fișierul nu este sigur" "%1$s (%2$s) a distribuit acest mesaj deoarece nu erați în cameră când a fost trimis." "%1$s a distribuit acest mesaj deoarece nu erați în cameră când a fost trimis." "Această cameră a fost configurată astfel încât noii membri să poată citi istoricul. %1$s" @@ -477,10 +528,19 @@ Sunteți sigur că doriți să continuați?" "%1$s Android" "Rageshake pentru a raporta erori" "Captură de ecran" + "Autentificare" + "Creați un cont" + "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)." + "Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)." + "Furnizor de cont" + "exemplu.ro" + "Introduceți furnizorul contului dumneavoastră" + "Alegeți un furnizor de cont" "%1$s: %2$s" "Opțiuni" "Ștergeți %1$s" "Setări" + "Voturi permise per persoană" "Nimeni nu își partajează locația" "Se partajează locația în timp real" @@ -489,6 +549,8 @@ Sunteți sigur că doriți să continuați?" "%1$d persoane" "Pe hartă" + "Homeserverul dumneavoastră se află în rețeaua locală. Pentru a se conecta, %1$s are nevoie de permisiunea de a accesa dispozitive din această rețea." + "Permiteți accesul la rețeaua locală" "Selectarea fișierelor media a eșuat, încercați din nou." "Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici." "Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință" @@ -535,6 +597,11 @@ Sunteți sigur că doriți să continuați?" "Spații" "Partajat %1$s" "Pe hartă" + + "%1$d s-a alăturat" + "%1$d s-au alăturat" + "%1$d s-au alăturat" + "Mesajul nu a fost trimis deoarece identitatea digitala verificată a lui %1$s s-a schimbat." "Mesajul nu a fost trimis deoarece %1$s nu a verificat toate dispozitivele." "Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive." @@ -546,4 +613,6 @@ Sunteți sigur că doriți să continuați?" "Nu aveți acces la acest mesaj" "Nu s-a putut decripta mesajul" "Acest mesaj a fost blocat fie pentru că nu ați verificat dispozitivul, fie pentru că expeditorul trebuie să vă verifice identitatea digitală." + "Dispozitivul dumneavoastră este prea vechi, este necesar un dispozitiv cu Android 8 sau o versiune mai nouă." + "Afișarea hărților nu este suportată" diff --git a/libraries/ui-strings/src/main/res/values-sv/translations.xml b/libraries/ui-strings/src/main/res/values-sv/translations.xml index 4e46cd4653..cdaa72f5d3 100644 --- a/libraries/ui-strings/src/main/res/values-sv/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -440,7 +440,7 @@ Anledning:%1$s." "Öppna i Apple Maps" "Öppna i Google Maps" "Öppna i OpenStreetMap" - "Dela fäst plats" + "Dela vald plats" "Utrymmen som du har skapat eller gått med i." "%1$s • %2$s" "Utrymmen" diff --git a/libraries/ui-strings/src/main/res/values-uk/translations.xml b/libraries/ui-strings/src/main/res/values-uk/translations.xml index 9e93cdb891..8545325603 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -20,6 +20,16 @@ "Повна адреса буде %1$s" "Подробиці шифрування" "Розгорнути текстове поле повідомлення" + "Галерея, вкладення %1$d" + "Галерея, невдалося сканувати %1$d" + "Галерея, зображення %1$d" + + "Галерея, ще %1$d медіа" + "Галерея, ще %1$d медіа" + "Галерея, ще %1$d медіа" + + "Галерея, сканування %1$d" + "Галерея, відео %1$d" "Cховати пароль" "Інформація" "Приєднатися до виклику" @@ -518,6 +528,14 @@ "%1$s Android" "Повідомляти про помилку потрусивши телефон" "Знімок екрана" + "Увійти" + "Створити обліковий запис" + "Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)." + "Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)." + "Постачальник облікового запису" + "example.com" + "Вкажіть постачальника вашого облікового запису" + "Оберіть постачальника облікового запису" "%1$s: %2$s" "Варіанти" "Вилучити %1$s" diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml index c2ff955eac..171551d71e 100644 --- a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -1,20 +1,29 @@ + "新增大頭照" "新增反應:%1$s" "地址" "大頭貼" + "清除搜尋欄位" "最小化訊息文字欄位" + "減少每人可投的票數" + "增加每人可投的票數上限" + "自訂表情符號" "刪除" "已輸入 %1$d 個位數" + "持續時間:%1$s" "編輯大頭照" "完整地址為 %1$s" "加密詳細資訊" "展開訊息文字欄位" "隱藏密碼" + "資訊" "加入通話" "跳至底部" + "跳至未讀訊息" + "跳到第一則未讀訊息" "將地圖移動到我的位置" "僅限提及" "已關閉通知" @@ -25,33 +34,42 @@ "第 %1$d 頁" "暫停" "語音訊息,時長:%1$s,目前位置:%2$s" + "照片預覽" "PIN 碼欄位" "固定位置" "播放" "播放速度" "投票" "投票已結束" + "位置:%1$s" "QR Code" "使用 %1$s 回應" "用其他表情符號回應" - "%1$s 和 %2$s 已讀" + "上一則訊息已被 %1$s 和 %2$s 閱讀" - "%1$s 與其他 %2$d 個人已讀" + "前一則訊息已被 %1$s 及另外 %2$d 人閱讀" - "%1$s 已讀" + "上一則訊息由 %1$s 閱讀" + "顯示所有讀取回條" "移除 %1$s 的反應" "移除反應 %1$s" "聊天室大頭照" "傳送檔案" "傳送者位置" + "由 %1$s 於 %2$s 傳送" "需要限時動作,您有一分鐘可以驗證" + "設定,需採取行動" "顯示密碼" "開始通話" "開始視訊通話" "開始語音通話" + "狀態" + "%1$s 中的討論串" + "%1$s 中的討論串" "墓碑聊天室" "使用者大頭照" "使用者選單" + "影片預覽" "檢視大頭照" "檢視詳細資訊" "語音訊息,時長:%1$s" @@ -66,6 +84,7 @@ "通話" "取消" "暫時取消" + "選擇檔案" "選擇照片" "清除" "關閉" @@ -85,12 +104,16 @@ "停用帳號" "拒絕" "拒絕並封鎖" + "刪除" + "刪除帳號" + "刪除檔案" "刪除投票" "取消全選" "停用" "捨棄" "關閉" "完成" + "下載" "編輯" "編輯標題" "編輯投票" @@ -121,6 +144,7 @@ "管理帳號與裝置" "管理裝置" "管理聊天室" + "標記為已讀" "聊天" "最小化" "下一步" @@ -175,9 +199,11 @@ "翻譯" "再試一次" "取消釘選" + "視訊通話" "檢視" "在時間軸中檢視" "檢視來源" + "語音通話" "是" "是的,再試一次" "您的伺服器現在支援新的、更快的協定。立即登出並重新登入以進行升級。現在這樣做將協助您避免在稍後移除舊協定時被強制登出。" @@ -197,7 +223,10 @@ "測試版" "封鎖的使用者" "泡泡" + "通話被拒" + "通話中" "開始通話" + "您拒絕了通話" "聊天室備份" "已複製到剪貼簿" "著作權" @@ -239,7 +268,15 @@ "檔案已儲存至 Downloads" "轉寄訊息" "經常使用" + "圖庫" + + "%1$d 個附件⋯⋯" + + + "%1$d 個媒體項目⋯⋯" + "GIF" + "群組通話進行中" "圖片" "回覆 %1$s" "安裝 APK" @@ -273,6 +310,7 @@ "沒有空間名稱" "未加密" "離線" + "正在通話" "開放原始碼授權條款" "或" "其他選項" @@ -285,6 +323,7 @@ "請稍等…" "您確定要結束這項投票嗎?" "投票:%1$s" + "投票" "總票數:%1$s" "結果將在投票結束後公佈" @@ -374,6 +413,7 @@ "開啟通知" "不支援的通話" "不支援的事件" + "%1$s 已發起通話" "使用者名稱" "驗證已取消" "驗證完成" @@ -395,6 +435,11 @@ "正在等待即時位置……" "任何人都可以檢視歷史" "您" + "無法下載。" + "發生錯誤" + "正在掃描⋯⋯" + "預覽與下載功能已停用。" + "檔案不安全" "因為您當時不在聊天室內,所以 %1$s (%2$s) 分享了此訊息。" "因為您當時不在聊天室裡面,因此 %1$s 分享了此訊息。" "此聊天室被設定為方便新成員閱讀歷史紀錄。%1$s" @@ -445,6 +490,9 @@ "抱歉,發生錯誤" "🔐️ 在 %1$s 上加入我" "嘿,來 %1$s 和我聊天:%2$s" + "即時位置分享" + "正在分享位置" + "%1$s 即時位置" "%1$s Android" "憤怒搖晃以回報臭蟲" "螢幕截圖" @@ -452,6 +500,15 @@ "選項" "移除 %1$s" "設定" + "每人可投票數" + "沒有人在分享位置" + "分享即時位置" + + "%1$d 人" + + "在地圖上" + "您的家伺服器位於區域網路。要連線,%1$s 需要取得存取此網路上裝置的權限。" + "允許存取區域網路" "選取媒體失敗,請再試一次。" "按一下訊息,然後選擇「%1$s」以加入至此。" "釘選重要訊息,如此才能輕鬆發現" @@ -496,6 +553,9 @@ "空間" "已分享 %1$s" "在地圖上" + + "%1$d 人已加入" + "因為 %1$s 的驗證數位身份已重設,因此未傳送訊息。" "訊息未傳送,因為 %1$s 尚未驗證所有裝置。" "因為您尚未驗證一個或多個裝置,因此未傳送訊息" @@ -507,4 +567,6 @@ "您無法存取此則訊息" "無法解密訊息" "此訊息被封鎖,原因可能是您尚未驗證裝置,或是寄件者需要驗證您的數位身分。" + "您的裝置版本過舊,需要執行 Android 8 或更新版本的裝置。" + "不支援顯示地圖" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 0ecd99f6c8..b363ba9498 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -18,6 +18,14 @@ "完整地址为 %1$s" "加密详情" "展开消息文本框" + "媒体组,第 %1$d 个附件" + "媒体组,第 %1$d 个扫描失败" + "媒体组,第 %1$d 个图像" + + "媒体组,其余 %1$d 个媒体" + + "媒体组,正在扫描第 %1$d 个" + "媒体组,第 %1$d 个视频" "隐藏密码" "信息" "加入通话" @@ -268,7 +276,7 @@ "文件已保存到“下载”" "转发消息" "常用" - "图库" + "媒体组" "%1$d 个附件…" @@ -496,6 +504,14 @@ "%1$s Android" "摇一摇以报告 bug" "屏幕截图" + "登录" + "创建账户" + "如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。" + "在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。" + "账户提供者" + "example.com" + "输入你的账户提供者" + "选择账户提供者" "%1$s:%2$s" "选项" "移除 %1$s" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 49dedb49e1..0d27ff5a9f 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -19,6 +19,15 @@ "The full address will be %1$s" "Encryption details" "Expand message text field" + "Gallery, attachment %1$d" + "Gallery, failed scan %1$d" + "Gallery, image %1$d" + + "Gallery, %1$d more media" + "Gallery, %1$d more medias" + + "Gallery, scanning %1$d" + "Gallery, video %1$d" "Hide password" "Info" "Join call" @@ -507,6 +516,14 @@ Are you sure you want to continue?" "%1$s Android" "Rageshake to report bug" "Screenshot" + "Sign in" + "Create account" + "If your account is provided by your company or a community, then change the account provider (example: companyname.com)." + "Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)." + "Account provider" + "example.com" + "Enter your account provider" + "Choose an account provider" "%1$s: %2$s" "Options" "Remove %1$s" diff --git a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt index 0f7e02c7a8..3697125922 100644 --- a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserListDataSource.kt @@ -11,8 +11,24 @@ package io.element.android.libraries.usersearch.api import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +/** + * Low-level access to the homeserver's user directory; see [UserRepository] for the search used by the UI. + */ interface UserListDataSource { - // TODO should probably have a flow + /** + * Searches the user directory, returning an empty list rather than failing when the request does not succeed. + * TODO should probably have a flow + * + * @param query the text to look for in user ids and display names. + * @param count the maximum number of results to return. + */ suspend fun search(query: String, count: Long): List + + /** + * Fetches one user's profile. + * + * @param userId the user whose profile is requested. + * @return the profile, or `null` when it could not be retrieved. + */ suspend fun getProfile(userId: UserId): MatrixUser? } diff --git a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt index f7e5367a9a..b3be565ca5 100644 --- a/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt +++ b/libraries/usersearch/api/src/main/kotlin/io/element/android/libraries/usersearch/api/UserRepository.kt @@ -10,6 +10,15 @@ package io.element.android.libraries.usersearch.api import kotlinx.coroutines.flow.Flow +/** + * Searches for users to start a conversation with or invite, combining the homeserver directory with what the app already knows. + */ interface UserRepository { + /** + * Runs a search and emits its progress, so the UI can show local results before the directory answers. + * A query that is a well-formed user id other than the current user's also yields that user, even when the directory does not know them. + * + * @param query the text typed by the user. + */ fun search(query: String): Flow } diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt index 0229ed991f..1b576a6d72 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt @@ -13,7 +13,17 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import kotlin.time.Duration +/** + * Creates the presenter of a single voice message player, one per voice message shown in a timeline. + */ interface VoiceMessagePresenterFactory { + /** + * @param eventId the event carrying the voice message, or `null` while it is still a local echo. + * @param mediaSource where the audio is fetched from. + * @param mimeType the MIME type of the audio, or `null` when the event did not state it. + * @param filename the file name to display, or `null` when the event did not state it. + * @param duration the length of the recording, used to render the waveform before the audio is downloaded. + */ fun createVoiceMessagePresenter( eventId: EventId?, mediaSource: MediaSource, diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt index 5e4c9ae132..4823c67c6a 100644 --- a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt @@ -71,14 +71,17 @@ class DefaultVoiceRecorder( override val state: StateFlow = _state @RequiresPermission(Manifest.permission.RECORD_AUDIO) - override suspend fun startRecord() { + override suspend fun startRecord() = lock.withLock { + if (recordingJob != null) { + Timber.w("Voice recorder is already recording, ignoring this start") + return@withLock + } + Timber.i("Voice recorder started recording") outputFile = fileManager.createFile() .also(encoder::init) - lock.withLock { - levels.clear() - } + levels.clear() val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it } diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt index e3ff6f9198..158b9ba481 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt @@ -39,6 +39,7 @@ import kotlin.time.TestTimeSource class DefaultVoiceRecorderTest { private val fakeFileSystem = FakeFileSystem() private val timeSource = TestTimeSource() + private val audioReaderFactory = FakeAudioReaderFactory(audio = AUDIO) @Test fun `it emits the initial state`() = runTest { @@ -109,6 +110,32 @@ class DefaultVoiceRecorderTest { } } + @Test + fun `when startRecord is called twice, the second call is ignored`() = runTest { + val voiceRecorder = createDefaultVoiceRecorder() + voiceRecorder.state.test { + assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) + + voiceRecorder.startRecord() + voiceRecorder.startRecord() + assertThat(audioReaderFactory.createdCount).isEqualTo(1) + + skipItems(1) + timeSource += 5.seconds + skipItems(2) + voiceRecorder.stopRecord() + assertThat(awaitItem()).isEqualTo( + VoiceRecorderState.Finished( + file = File(FILE_PATH), + mimeType = MimeTypes.Ogg, + waveform = List(100) { 1f }, + duration = 5.seconds, + ) + ) + expectNoEvents() + } + } + @Test fun `when cancelled, it deletes the file`() = runTest { val voiceRecorder = createDefaultVoiceRecorder() @@ -128,9 +155,7 @@ class DefaultVoiceRecorderTest { return DefaultVoiceRecorder( dispatchers = testCoroutineDispatchers(), timeSource = timeSource, - audioReaderFactory = FakeAudioReaderFactory( - audio = AUDIO, - ), + audioReaderFactory = audioReaderFactory, encoder = FakeEncoder(fakeFileSystem), config = AudioConfig( format = audioFormat, diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt index d4bd241aaa..d3e434538e 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt @@ -16,7 +16,11 @@ import io.element.android.libraries.voicerecorder.impl.audio.AudioReader class FakeAudioReaderFactory( private val audio: List