diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 85377d905c..cdf47a6b1a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -13,8 +13,8 @@ "hostRules": [ { "matchHost": "https://repo1.maven.org/maven2/", - "concurrentRequestLimit": 10, - "maxRequestsPerSecond": 10, + "concurrentRequestLimit": 5, + "maxRequestsPerSecond": 5, "timeout": 10000, } ], diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa9be929ea..b1ba9dd010 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,7 +56,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APKs @@ -74,7 +74,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-debug path: | diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index b1d7204168..589f0a329d 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -61,7 +61,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug Gplay Enterprise APK @@ -79,7 +79,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug Enterprise APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-enterprise-debug path: | diff --git a/.github/workflows/generate_github_pages.yml b/.github/workflows/generate_github_pages.yml index 763936e3d6..a180afb07e 100644 --- a/.github/workflows/generate_github_pages.yml +++ b/.github/workflows/generate_github_pages.yml @@ -24,7 +24,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 1195c7b53d..25aa0f6eac 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -53,7 +53,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Assemble debug APK @@ -63,7 +63,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - name: Upload APK as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-apk-maestro path: | @@ -124,8 +124,21 @@ jobs: disk-size: 3G script: | .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk + - name: Set up Python + if: always() + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: 3.14 + - name: End all the remaining sessions of the Maestro account + if: always() + continue-on-error: true + env: + MAS_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} + run: | + pip install requests + python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement - name: Upload test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-results path: | diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index fe38c0138f..5c3e33f577 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -43,22 +43,22 @@ jobs: java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: false - name: ⚙️ Run unit tests, debug and release - run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue - name: 📸 Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: 📈 Generate kover report and verify coverage run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES - name: ✅ Upload kover report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-results path: | @@ -85,14 +85,14 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Dependency analysis run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES - name: Upload dependency analysis if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dependency-analysis path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1f6289a8ea..1fa443c0f5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -74,7 +74,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Set up Python 3.12 @@ -113,14 +113,14 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Konsist tests run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: konsist-report path: | @@ -154,7 +154,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run compose tests @@ -188,7 +188,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build Gplay Debug @@ -199,7 +199,7 @@ jobs: run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: linting-report path: | @@ -233,14 +233,14 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt - run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon + run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: detekt-report path: | @@ -274,14 +274,14 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check - run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ktlint-report path: | @@ -336,7 +336,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 + - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 upload_reports: name: Project Check Suite diff --git a/.github/workflows/recordScreenshots.yml b/.github/workflows/recordScreenshots.yml index efac08a99f..e6580d2f7b 100644 --- a/.github/workflows/recordScreenshots.yml +++ b/.github/workflows/recordScreenshots.yml @@ -60,7 +60,7 @@ jobs: java-version: '21' # Add gradle cache, this should speed up the process - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.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 8f264aa6de..316fcc27ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,13 +37,15 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check Matrix Rust SDK is not a nightly version + run: ./tools/release/check_no_nightly_sdk.sh - name: Use JDK 21 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Create app bundle env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} @@ -57,7 +59,7 @@ jobs: ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-gplay-bundle-unsigned path: | @@ -87,13 +89,15 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check Matrix Rust SDK is not a nightly version + run: ./tools/release/check_no_nightly_sdk.sh - name: Use JDK 21 uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - name: Create APKs env: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} @@ -101,7 +105,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload apks as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-fdroid-apks-unsigned path: | diff --git a/.github/workflows/scripts/recordScreenshots.sh b/.github/workflows/scripts/recordScreenshots.sh index d4122c22bf..952c552ce4 100755 --- a/.github/workflows/scripts/recordScreenshots.sh +++ b/.github/workflows/scripts/recordScreenshots.sh @@ -51,9 +51,10 @@ if [[ -z ${REPO} ]]; then exit 1 fi -echo "Deleting previous screenshots" -./gradlew removeOldSnapshots --stacktrace --warn $GRADLE_ARGS - +# Note: the previous screenshots are deliberately kept, so that Paparazzi can leave the ones that only +# differ by less than `maxPercentDifference` untouched instead of rewriting their Git LFS blob. The +# screenshots of previews that no longer exist are pruned afterwards by `pruneObsoleteSnapshots`, which +# `recordPaparazziDebug` is finalized by. echo "Record screenshots" ./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 4febf272a9..d2b57a771e 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -50,7 +50,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Build debug code and test fixtures diff --git a/.github/workflows/sync-enterprise-submodule.yml b/.github/workflows/sync-enterprise-submodule.yml new file mode 100644 index 0000000000..d7bb28d776 --- /dev/null +++ b/.github/workflows/sync-enterprise-submodule.yml @@ -0,0 +1,85 @@ +name: Sync Enterprise submodule + +on: + workflow_dispatch: + push: + branches: [ develop ] + paths: [ enterprise ] + schedule: + # At 06:00 UTC every day + - cron: '0 6 * * *' + +permissions: {} + +jobs: + sync-enterprise-submodule: + runs-on: ubuntu-latest + # Skip in forks + if: github.repository == 'element-hq/element-x-android' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Add SSH private keys for submodule repositories + uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 + with: + ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }} + - name: Check enterprise submodule reference + id: check + env: + EVENT_NAME: ${{ github.event_name }} + run: | + current=$(git ls-tree HEAD enterprise | awk '{print $3}') + # Do not init the submodule: the current commit may be on a deleted branch. + git init -q "$RUNNER_TEMP/enterprise" + git -C "$RUNNER_TEMP/enterprise" fetch -q git@github.com:element-hq/element-android-enterprise.git main + main=$(git -C "$RUNNER_TEMP/enterprise" rev-parse FETCH_HEAD) + if git -C "$RUNNER_TEMP/enterprise" merge-base --is-ancestor "$current" "$main" 2>/dev/null; then + on_main=true + else + on_main=false + fi + echo "Current: $current, main: $main, current is on main: $on_main" + + if [ "$current" = "$main" ]; then + echo "Submodule is already up to date." + echo "update=false" >> "$GITHUB_OUTPUT" + elif [ "$EVENT_NAME" = "push" ] && [ "$on_main" = "true" ]; then + echo "Submodule targets a commit on main, nothing to do." + echo "update=false" >> "$GITHUB_OUTPUT" + else + echo "update=true" >> "$GITHUB_OUTPUT" + fi + + { + echo "body<> "$GITHUB_OUTPUT" + + if [ "$current" != "$main" ]; then + # Update the submodule reference without cloning it. + git update-index --cacheinfo "160000,$main,enterprise" + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -q -m "Update enterprise submodule to latest main" -m "Update to $main" + fi + - name: Create Pull Request to update the enterprise submodule + # On schedule and manual runs, also run when there is nothing to update, so that a stale PR gets closed. + if: ${{ steps.check.outputs.update == 'true' || github.event_name != 'push' }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} + title: Update enterprise submodule to latest main + body: ${{ steps.check.outputs.body }} + branch: sync-enterprise-submodule + base: develop + delete-branch: true + labels: PR-Element-Pro diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 9065e6d4e9..8d93e958c6 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -25,7 +25,7 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.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 23b69ffa42..6222ac024c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,22 +68,22 @@ jobs: distribution: 'temurin' # See 'Supported distributions' for available options java-version: '21' - name: Configure gradle - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: ⚙️ Run unit tests - run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Generate kover coverage report run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES - name: 🚫 Upload kover failed coverage reports if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-error-report path: | @@ -95,7 +95,7 @@ jobs: - name: 🚫 Upload test results on error if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: tests-and-screenshot-tests-results path: | @@ -113,7 +113,7 @@ jobs: # https://github.com/codecov/codecov-action - name: ☂️ Upload coverage reports to codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index cc87bc5140..4f368d4513 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -2,6 +2,6 @@ \ No newline at end of file diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml index 83f834da04..27700109f6 100644 --- a/.maestro/tests/account/logout.yaml +++ b/.maestro/tests/account/logout.yaml @@ -2,17 +2,23 @@ appId: ${MAESTRO_APP_ID} --- - tapOn: id: "home_screen-settings" -- tapOn: - id: "settings-user_profile" -- assertVisible: "Your account" - tapOn: "Remove this device" - takeScreenshot: build/maestro/900-SignOutScreen - back - tapOn: "Remove this device" +# Ensure pressing back cancels +- back +- tapOn: "Remove this device" # Ensure cancel cancels +- tapOn: "Cancel" +- tapOn: "Remove this device" +- tapOn: + id: "sign-out-submit" +# Cancel the dialog cancels the logout - tapOn: id: "dialog-negative" -- tapOn: "Remove this device" +- tapOn: + id: "sign-out-submit" - tapOn: id: "dialog-positive" - runFlow: ../assertions/assertInitDisplayed.yaml diff --git a/AGENTS.md b/AGENTS.md index a4a73ca965..dc34ebd571 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ PRs must meet these rules. - Create previews for **all main states** of a Composable. - Use `@PreviewsDayNight` for consistency. -- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. +- Use `PreviewParameterProvider` (e.g., `FooStatePreviewParam`) to provide states. Always add new states to existing providers at the end of the sequence. - Wrap previews in `ElementPreview { ... }`. - When writing tests, never try to record the screenshots, the CI will do it. diff --git a/CHANGES.md b/CHANGES.md index e0833dbe36..e303af54d7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,75 @@ +Changes in Element X v26.09.4 +============================= + +## Hotfix version: + +Fixes an issue with the room list not displaying all activity from unread messages. + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.3...v26.09.4 + +Changes in Element X v26.09.3 +============================= + + + +## What's Changed +### ✨ Features +* Integrate the new timeline renderer behind a feature flag by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7722 +* Add headers (`h1-h6`) tag support for the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7754 +### 🙌 Improvements +* Add smooth animation to voice messages play progress by @bxdxnn in https://github.com/element-hq/element-x-android/pull/7698 +* Add linkification to the new timeline renderer by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7731 +### 🐛 Bugfixes +* Make global search use a `Joined` filter for room results by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7695 +* Fix flaky test in `TimelinePresenterTest` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7702 +* Close the threaded timelines created to create a poll, share a location or send media by @bmarty in https://github.com/element-hq/element-x-android/pull/7705 +* Start sync manually after completing the auth flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7734 +* Fix forwarding an event not working in threads by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7743 +* Fix blank screen at launch when the homeserver expires the sliding sync session by @bmarty in https://github.com/element-hq/element-x-android/pull/7692 +* Fix unread notification count in room list by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7749 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7699 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/7736 +### 🧱 Build +* Reduce parallel JVMs spawned for tests by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7701 +* Replace git flow with regular git commands in the release script V2 by @bmarty in https://github.com/element-hq/element-x-android/pull/7707 +* Remove Roborazzi, use Paparazzi for Compound by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7735 +* Fix regex for invalid placeholders by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7738 +* Add a script to install and launch an APK given a PR URL by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7752 +### 🚧 In development 🚧 +* New timeline event renderer: add new modules by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7721 +* Add recent search results to global search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7742 +### Dependency upgrades +* Update dependency io.sentry:sentry-android to v8.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7685 +* Update coil to v3.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7693 +* Update kotlinpoet to v2.4.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7709 +* Update plugin sonarqube to v7.5.0.8588 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7671 +* Update roborazzi to v1.74.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7712 +* Update dependency io.element.android:element-call-embedded to v0.26.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7715 +* Use patched Konsist version (forked, 0.18.0) by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7719 +* Update dependency net.zetetic:sqlcipher-android to v4.19.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7716 +* Update metro to v1.4.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7711 +* Update actions/setup-java action to v6.0.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7728 +* Update dependency androidx.compose:compose-bom to v2026.09.00 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7730 +* Update dependency androidx.sqlite:sqlite-ktx to v2.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7729 +* Update dependencyAnalysis to v3.19.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7755 +* Update media3 to v1.11.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/7756 +* Upgrade SDK bindings to `v26.09.26` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/7780 +### Others +* Let the user give a reason when removing a message by @hayaksi1 in https://github.com/element-hq/element-x-android/pull/7538 +* Iterate on client creation by @bmarty in https://github.com/element-hq/element-x-android/pull/7591 +* Iteration on Acount provider by @bmarty in https://github.com/element-hq/element-x-android/pull/7703 +* Do not report detekt LargeClass issues on test classes by @bmarty in https://github.com/element-hq/element-x-android/pull/7713 +* Let the user give an optional reason when removing a message by @bmarty in https://github.com/element-hq/element-x-android/pull/7679 +* Make NotificationResultProcessor.emit wait until the batch is processed by @Johennes in https://github.com/element-hq/element-x-android/pull/7640 +* Settings iteration: move user status and update multi-account section by @bmarty in https://github.com/element-hq/element-x-android/pull/7741 +* Update remove message confirmation dialog. by @bmarty in https://github.com/element-hq/element-x-android/pull/7757 +* Add a script to end all the sessions of a Matrix account using MAS by @bmarty in https://github.com/element-hq/element-x-android/pull/7762 +* Settings iteration: split account settings and application settings. by @bmarty in https://github.com/element-hq/element-x-android/pull/7750 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v26.09.2...v26.09.3 + Changes in Element X v26.09.2 ============================= diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a297ea7702..706a82f0d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ import extension.allFeaturesImpl import extension.allLibrariesImpl import extension.allServicesImpl import extension.buildConfigFieldStr +import extension.environmentOrGradleValue import extension.locales import extension.setupDependencyInjection import extension.testCommonDependencies @@ -88,13 +89,14 @@ android { storePassword = "android" } register("nightly") { - keyAlias = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYID") - ?: project.property("signing.element.nightly.keyId") as? String? - keyPassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD") - ?: project.property("signing.element.nightly.keyPassword") as? String? - storeFile = file("./signature/nightly.keystore") - storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD") - ?: project.property("signing.element.nightly.storePassword") as? String? + keyAlias = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYID", "signing.element.nightly.keyId") + keyPassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD", "signing.element.nightly.keyPassword") + storeFile = file( + environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREFILE", "signing.element.nightly.storeFile") + ?.ifBlank { null } + ?: "./signature/nightly.keystore" + ) + storePassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD", "signing.element.nightly.storePassword") } } @@ -278,7 +280,6 @@ dependencies { implementation(projects.features.migration.api) implementation(projects.appnav) implementation(projects.appconfig) - implementation(projects.libraries.uiStrings) implementation(projects.services.analytics.compose) if (ModulesConfig.pushProvidersConfig.includeFirebase) { @@ -296,18 +297,13 @@ dependencies { implementation(libs.androidx.splash) implementation(libs.androidx.core) implementation(libs.androidx.corektx) - implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.androidx.preference) - implementation(libs.coil) implementation(platform(libs.network.okhttp.bom)) implementation(libs.network.okhttp.logging) - implementation(libs.serialization.json) - - implementation(libs.matrix.emojibase.bindings) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) @@ -340,6 +336,9 @@ licensee { ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") // Ignore dependency that are not third-party licenses to us. ignoreDependencies(groupId = "io.element.android") + // The rustls-platform-verifier dependency does not provide a license file in their maven repo, but there are MIT and Apache licenses in + // the source code repo: see https://github.com/rustls/rustls-platform-verifier + ignoreDependencies(groupId = "org.rustls", artifactId = "rustls-platform-verifier") } fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) { diff --git a/app/proguard/foss/default-proguard-rules.keep b/app/proguard/foss/default-proguard-rules.keep index a6dce2f0ce..604e1c55fd 100644 --- a/app/proguard/foss/default-proguard-rules.keep +++ b/app/proguard/foss/default-proguard-rules.keep @@ -1,2 +1,9 @@ # Don't obfuscate anything for non-enterprise builds -dontobfuscate + +# Strip PostHog's Firebase push token integration. We don't use it and it breaks F-Droid. +# Removing the contents of this method will mark the Firebase-related classes as unused, +# which will allow R8 to remove them from the final build. +-assumevalues class com.posthog.android.PostHogAndroidConfig { + boolean getCapturePushNotificationSubscriptions() return false; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5f08e0795..9193c6de71 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,9 +53,10 @@ --> diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index a3712ebbf6..b39dc353fc 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -9,6 +9,7 @@ package io.element.android.x import android.app.Application +import android.os.StrictMode import androidx.compose.material3.ComposeMaterial3Flags.isAnchoredDraggableComponentsStrictOffsetCheckEnabled import androidx.compose.material3.ExperimentalMaterial3Api import androidx.startup.AppInitializer @@ -38,6 +39,9 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config @OptIn(ExperimentalMaterial3Api::class) override fun onCreate() { super.onCreate() + + enableStrictMode() + AppInitializer.getInstance(this).apply { initializeComponent(CrashInitializer::class.java) initializeComponent(PlatformInitializer::class.java) @@ -51,4 +55,16 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner, Config // Remove once https://issuetracker.google.com/issues/477038695 is fixed. isAnchoredDraggableComponentsStrictOffsetCheckEnabled = false } + + private fun enableStrictMode() { + // Workaround to avoid NetworkOnMainThreadException when using the network on the main thread. + // This is a temporary solution to rustls doing the certificate verification using blocking network requests. + // It should be removed once the underlying issue is fixed. + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectNetwork() + .penaltyLog() + .build() + ) + } } diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index c5551f11aa..cc058d0f8f 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -70,6 +70,19 @@ class MainActivity : NodeActivity() { } } + /** + * The pre-Android 12 way into picture-in-picture for a native call. + * + * Everything else about it is installed where the call is drawn, which knows whose call it is. + * This cannot be: an Activity override has no session in scope. From Android 12 the system is told + * up front that this Activity would like to shrink, and the call handles it without ever coming + * through here. + */ + override fun onUserLeaveHint() { + super.onUserLeaveHint() + appBindings.nativeCallPip().onUserLeaveHint(this) + } + @Composable private fun MainContent(appBindings: AppBindings) { val migrationState = appBindings.migrationEntryPoint().present() diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index 638ba0e38c..e5411e739c 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -11,11 +11,12 @@ package io.element.android.x.di import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Multibinds -import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.callnative.api.NativeCallPip import io.element.android.features.enterprise.api.AppStartupHook import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.migration.api.MigrationEntryPoint import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -52,6 +53,13 @@ interface AppBindings { fun buildMeta(): BuildMeta + /** + * For picture-in-picture. Only an Activity is told it is being left, and only the call knows + * whether that is worth shrinking for, so `MainActivity` needs this much of the native call and + * no more. + */ + fun nativeCallPip(): NativeCallPip + fun sentrySdkDsn(): SentrySdkDsn? @Multibinds(allowEmpty = true) diff --git a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt index 2a844b1331..1d2504ae67 100644 --- a/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt +++ b/app/src/main/kotlin/io/element/android/x/initializer/PlatformInitializer.kt @@ -8,11 +8,14 @@ package io.element.android.x.initializer +import android.annotation.SuppressLint import android.content.Context import android.system.Os +import android.util.Log import androidx.startup.Initializer import io.element.android.features.rageshake.api.logs.createWriteToFilesConfiguration import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.tracing.TracingConfiguration import io.element.android.x.di.AppBindings @@ -24,6 +27,15 @@ private const val ELEMENT_X_TARGET = "elementx" class PlatformInitializer : Initializer { override fun create(context: Context) { + // Init the SDK with the Android Context, so it can be used with rustls and other Android specific features + runCatchingExceptions { + org.matrix.rustsdk.Android.init(context) + }.onFailure { + // We can't log this to Timber since the logs aren't initialized yet, so we log it to Logcat instead + @SuppressLint("LogNotTimber") + Log.e("PlatformInitializer", "Failed to init the SDK with an Android Context. The app probably won't work properly.", it) + } + val appBindings = context.bindings() val tracingService = appBindings.tracingService() val platformService = appBindings.platformService() @@ -41,6 +53,8 @@ class PlatformInitializer : Initializer { sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() }, ) bugReporter.setCurrentTracingLogLevel(logLevel.name) + + // Init the tracing service with the configuration so we have logging enabled as soon as possible platformService.init(tracingConfiguration) // Also set env variable for rust back trace Os.setenv("RUST_BACKTRACE", "1", true) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 96add8d148..98b80d94cb 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -30,5 +30,384 @@ home lan localdomain + + + + abitabdv.crl.certum.pl + abitabev.crl.certum.pl + abitabov.crl.certum.pl + alpiro.crl.sectigo.com + anticdv.crl.certum.pl + antrustovsslg2r30ca.crl.certum.pl + autontrustprodvtlsg2r31ca.crl.certum.pl + bitcert.crl.sectigo.com + btdvtlsr35g2ca.crl.certum.pl + c.cf-b.ssl.com + c.cf-i.ssl.com + c.pki.goog + ca1.agid.gov.it + cdn.d-trust-cloudcrl.net + cdp.disig.sk + cdp.elektronicznypodpis.pl + cdp.geotrust.com + cdp.rapidssl.com + cdp.thawte.com + cdp1.disig.sk + cdp1.pca.dfn.de + cdpa.digitalcertvalidation.com + cdpb.digitalcertvalidation.com + cdpc.digitalcertvalidation.com + cdpd.digitalcertvalidation.com + cdpe.digitalcertvalidation.com + cdpf.digitalcertvalidation.com + cdpg.digitalcertvalidation.com + cdph.digitalcertvalidation.com + cdpi.digitalcertvalidation.com + certcloud.crl.trust-provider.com + certdata.crl.sectigo.com + certera.crl.sectigo.com + certificates.godaddy.com + certum-crl.wt.trustasia.com + certum.crl.sheca.com + certumdvtlsg2e39ca.crl.certum.pl + certumdvtlsg2r39ca.crl.certum.pl + certumevtlsg2e39ca.crl.certum.pl + certumevtlsg2r39ca.crl.certum.pl + certumovtlsg2e39ca.crl.certum.pl + certumovtlsg2r39ca.crl.certum.pl + cfcadveccca.crl.certum.pl + cfcadvrsaca.crl.certum.pl + cfcaeveccca.crl.certum.pl + cfcaevrsaca.crl.certum.pl + cfcaoveccca.crl.certum.pl + cfcaovrsaca.crl.certum.pl + cnssldvtlsg2r36ca.crl.certum.pl + cnsslovtlsg2r36ca.crl.certum.pl + crl-01.r1.ica.amazontrust.com + crl-01.r1.ica.amznts.eu + crl-c.emsign.com + crl-pro.litessl.com + crl.anf.es + crl.apple.com + crl.businessid.telesec.de + crl.buypass.no + crl.catrust.oemssl.cn + crl.certigna.fr + crl.certsign.ro + crl.certum.pl + crl.cfca.com.cn + crl.cntrus.oemssl.cn + crl.cnwebtrust.oemssl.cn + crl.comodoca.com + crl.crlocsp.cn + crl.cybertrust.ne.jp + crl.d-trust.net + crl.dhimyotis.com + crl.digicert.cn + crl.digicert.eu + crl.e-szigno.hu + crl.e2m01.amazontrust.com + crl.e2m01.eu.amazontrust.com + crl.e2m02.amazontrust.com + crl.e2m03.amazontrust.com + crl.e2m04.amazontrust.com + crl.e2s14.amazontrust.com + crl.e2s15.amazontrust.com + crl.e2s16.amazontrust.com + crl.e2s17.amazontrust.com + crl.e2s18.amazontrust.com + crl.e2s19.amazontrust.com + crl.e2s20.amazontrust.com + crl.e2s21.amazontrust.com + crl.e3m01.amazontrust.com + crl.e3m01.eu.amazontrust.com + crl.e3m02.amazontrust.com + crl.e3m03.amazontrust.com + crl.e3m04.amazontrust.com + crl.e3s22.amazontrust.com + crl.e3s23.amazontrust.com + crl.e3s24.amazontrust.com + crl.e3s25.amazontrust.com + crl.e3s26.amazontrust.com + crl.e3s27.amazontrust.com + crl.e3s28.amazontrust.com + crl.e3s29.amazontrust.com + crl.emsign.com + crl.ensuredca.com + crl.firmaprofesional.com + crl.gdca.com.cn + crl.global.sheca.com + crl.globalsign.com + crl.globalsign.net + crl.godaddy.com + crl.harica.gr + crl.izenpe.com + crl.litessl.com + crl.netsolssl.com + crl.oem.trustca.net + crl.pcsp.telesec.de + crl.pki.goog + crl.quovadisglobal.com + crl.r1001.amazontrust.com + crl.r2001.amazontrust.com + crl.r2m01.amazontrust.com + crl.r2m01.eu.amazontrust.com + crl.r2m02.amazontrust.com + crl.r2m03.amazontrust.com + crl.r2m04.amazontrust.com + crl.r2s18.amazontrust.com + crl.r2s19.amazontrust.com + crl.r2s20.amazontrust.com + crl.r2s21.amazontrust.com + crl.r2s22.amazontrust.com + crl.r2s23.amazontrust.com + crl.r2s24.amazontrust.com + crl.r2s25.amazontrust.com + crl.r3001.amazontrust.com + crl.r4001.amazontrust.com + crl.r4m01.amazontrust.com + crl.r4m02.amazontrust.com + crl.r4m03.amazontrust.com + crl.r4m04.amazontrust.com + crl.root-e1.certainly.com + crl.root-r1.certainly.com + crl.rootca1.amazontrust.com + crl.rootca2.amazontrust.com + crl.rootca3.amazontrust.com + crl.rootca4.amazontrust.com + crl.rztrust.oemssl.cn + crl.sbca.telesec.de + crl.sectigo.com + crl.sectigochina.com + crl.serverid.telesec.de + crl.starfieldtech.com + crl.swisssign.ch + crl.trust-provider.cn + crl.trust-provider.com + crl.tuntrust.tn + crl.usertrust.com + crl.wt.trustasia.com + crl.xinchacha.oemssl.cn + crl.zw.oemssl.cn + crl05.actalis.it + crl06.actalis.it + crl09.actalis.it + crl1.ecert.gov.hk + crl13.actalis.it + crl14.actalis.it + crl15.actalis.it + crl2.microsoft.com + crl3.digicert.com + crl4.digicert.com + crls.certainly.com + crls.ssl.com + cyberfolks2.crl.certum.pl + depo.kamusm.gov.tr + digitaltrust.crl.sectigo.com + dnencrypt.crl.sectigo.com + dvtlsca2025-crl.e-szigno.hu + dvtlsca2026-crl.e-szigno.hu + e5.c.lencr.org + e6.c.lencr.org + e7.c.lencr.org + e8.c.lencr.org + ec2ca2017-crl1.e-szigno.hu + ec2ca2017-crl2.e-szigno.hu + ec2ca2017-crl3.e-szigno.hu + ec2sslca2017-crl1.e-szigno.hu + ec2sslca2017-crl2.e-szigno.hu + ec2sslca2017-crl3.e-szigno.hu + ec3ca2017-crl1.e-szigno.hu + ec3ca2017-crl2.e-szigno.hu + ec3ca2017-crl3.e-szigno.hu + ec3sslca2017-crl1.e-szigno.hu + ec3sslca2017-crl2.e-szigno.hu + ec3sslca2017-crl3.e-szigno.hu + eca.hinet.net + edvtlsca2023-crl.e-szigno.hu + edvtlsca2025-crl.e-szigno.hu + edvtlsca2026-crl.e-szigno.hu + emudhra.crl.sectigo.com + eosslca2017-crl1.e-szigno.hu + eosslca2017-crl2.e-szigno.hu + eosslca2017-crl3.e-szigno.hu + eovtlsca2023-crl.e-szigno.hu + eovtlsca2025-crl.e-szigno.hu + eovtlsca2026-crl.e-szigno.hu + eqca2017-crl1.e-szigno.hu + eqca2017-crl2.e-szigno.hu + eqca2017-crl3.e-szigno.hu + eqcpca2017-crl1.e-szigno.hu + eqcpca2017-crl2.e-szigno.hu + eqcpca2017-crl3.e-szigno.hu + eqoca2017-crl1.e-szigno.hu + eqoca2017-crl2.e-szigno.hu + eqoca2017-crl3.e-szigno.hu + eqtlsca2018-crl1.e-szigno.hu + eqtlsca2018-crl2.e-szigno.hu + eqtlsca2018-crl3.e-szigno.hu + eqtlsca2023-crl.e-szigno.hu + eqtlsca2025-crl.e-szigno.hu + eqtlsca2026-crl.e-szigno.hu + esafer.crl.sectigo.com + eue2m1.crl.root.amznts.eu + eue3m1.crl.root.amznts.eu + eur2m1.crl.root.amznts.eu + eveccca.crl.certum.pl + evrsaca.crl.certum.pl + gdcadv.crl.certum.pl + gdcaev.crl.certum.pl + gdcaov.crl.certum.pl + gdcatrustauthdvtlsg3r31ca.crl.certum.pl + gdcatrustauthevtlsg3r31ca.crl.certum.pl + gdcatrustauthovtlsg3r31ca.crl.certum.pl + geant.crl.sectigo.com + genious.crl.sectigo.com + geossldvtlsr34g2ca.crl.certum.pl + geosslevtlsr34g2ca.crl.certum.pl + geosslovtlsr34g2ca.crl.certum.pl + globaltrust.crl.sectigo.com + globessl.crl.sectigo.com + gogetssldv.crl.certum.pl + gogetsslev.crl.certum.pl + gogetsslov.crl.certum.pl + homepldvtlsg2r35ca.crl.certum.pl + homepldvtlsg2r36ca.crl.certum.pl + homeplovtlsg2r35ca.crl.certum.pl + homeplovtlsg2r36ca.crl.certum.pl + httpcrl.trust.telia.com + ica.navercloudtrust.com + ica.navercorp.com + issauth.crl.sectigo.com + itrusdv2.crl.certum.pl + itrusdvtlsr35g2ca.crl.certum.pl + itrusov2.crl.certum.pl + itrusovtlsr35g2ca.crl.certum.pl + itso.crl.sectigo.com + joyssldvtlsg2r33ca.crl.certum.pl + joysslovtlsg2r33ca.crl.certum.pl + kingnettechdv.crl.certum.pl + kingnettechev.crl.certum.pl + kingnettechov.crl.certum.pl + kr.crl.digicert.com + lh2.crl.certum.pl + namecheap.crl.sectigo.com + nazwassldvtlsg2e29ca.crl.certum.pl + nazwassldvtlsg2r29ca.crl.certum.pl + netartcadv2.crl.certum.pl + netartssldvtlsg2e31ca.crl.certum.pl + nijimo.crl.sectigo.com + nyalabscadv.crl.certum.pl + nyatworkdv.crl.certum.pl + omitsecurity.crl.sectigo.com + onesign.crl.sectigo.com + osslca2016-crl1.e-szigno.hu + osslca2016-crl2.e-szigno.hu + osslca2016-crl3.e-szigno.hu + ovtlsca2025-crl.e-szigno.hu + ovtlsca2026-crl.e-szigno.hu + pki-crl.atos.net + pki.telesec.de + pkipro.certsign.ro + positiwise.crl.sectigo.com + psw.crl.sectigo.com + public.wisekey.com + qiduodv.crl.certum.pl + qtlsca2018-crl1.e-szigno.hu + qtlsca2018-crl2.e-szigno.hu + qtlsca2018-crl3.e-szigno.hu + qtlsca2026-crl.e-szigno.hu + r10.c.lencr.org + r11.c.lencr.org + r12.c.lencr.org + r13.c.lencr.org + rca.navercloudtrust.com + rca.navercorp.com + repo.pubcert.jprs.jp + repo1.secomtrust.net + repository.secomtrust.net + repository.tls.hinet.net + rootca.twca.com.tw + rootglobaldv.crl.certum.pl + rootnetworksdv2.crl.certum.pl + shenzhendv.crl.certum.pl + shenzhenev.crl.certum.pl + shenzhenov.crl.certum.pl + shoperdvtlsg2r34ca.crl.certum.pl + shuididv.crl.certum.pl + shuidiev.crl.certum.pl + shuidiov.crl.certum.pl + soomadv.crl.certum.pl + ssl2ca2016-crl1.e-szigno.hu + ssl2ca2016-crl2.e-szigno.hu + ssl2ca2016-crl3.e-szigno.hu + sslca2014-crl1.e-szigno.hu + sslca2014-crl2.e-szigno.hu + sslca2014-crl3.e-szigno.hu + sslcom.crl.certum.pl + ssllimiteddv.crl.certum.pl + sslserver.twca.com.tw + subca.crl.certum.pl + titrust.crl.sectigo.com + tlcdvtlsr34g2ca.crl.certum.pl + tlcevtlsr34g2ca.crl.certum.pl + tlcovtlsr34g2ca.crl.certum.pl + tlsrootca2025-crl.e-szigno.hu + trustasia.crl.certum.pl + trustasiadv.crl.certum.pl + trustasiadvtlsr35g2ca.crl.certum.pl + trustasiaev.crl.certum.pl + trustasiaevtlse35g2ca.crl.certum.pl + trustasiaevtlsr35g2ca.crl.certum.pl + trustasiaov.crl.certum.pl + trustasiaovtlse35g2ca.crl.certum.pl + trustasiaovtlsr35g2ca.crl.certum.pl + trustocean.crl.certum.pl + tstlser20.crl.telesec.de + tstlsrr23.crl.telesec.de + unitedtrustov.crl.certum.pl + validation.identrust.com + verokey.crl.sectigo.com + vtrusdvtlsg3r31ca.crl.certum.pl + vtrusovtlsg3r31ca.crl.certum.pl + webnic.crl.sectigo.com + whitessldvtlsg2e36ca.crl.certum.pl + wosign.crl.certum.pl + wotrus-dvca.crl.certum.pl + wotrus-evca.crl.certum.pl + wotrus-ovca.crl.certum.pl + wotrus.crl.sectigo.com + wtca-cafiles.itrus.com.cn + wtca-crl.itrus.com.cn + www.accv.es + www.cert.fnmt.es + www.d-trust.net + www.microsoft.com + x1.c.lencr.org + x2.c.lencr.org + xcctrustdvtlsg3r31ca.crl.certum.pl + xcctrustevtlsg3r31ca.crl.certum.pl + xcctrustovtlsg3r31ca.crl.certum.pl + xinchacha2dv.crl.certum.pl + xinchacha2ov.crl.certum.pl + xinchachatrustdvtlsg2r34ca.crl.certum.pl + xinchachatrustevtlsg2r34ca.crl.certum.pl + xinchachatrustovtlsg2r34ca.crl.certum.pl + xinnetdvtlsr34g2ca.crl.certum.pl + xinnetevtlsr34g2ca.crl.certum.pl + xinnetovtlsr34g2ca.crl.certum.pl + ye.c.lencr.org + ye1.c.lencr.org + ye2.c.lencr.org + yektadv.crl.certum.pl + yektaov.crl.certum.pl + yr.c.lencr.org + yr1.c.lencr.org + yr2.c.lencr.org + zerossl.crl.sectigo.com + ziwit.crl.sectigo.com + zycatrustdvtlsg3r31ca.crl.certum.pl + zycatrustevtlsg3r31ca.crl.certum.pl + zycatrustovtlsg3r31ca.crl.certum.pl diff --git a/app/src/main/res/xml/network_security_config_element.xml b/app/src/main/res/xml/network_security_config_element.xml new file mode 100644 index 0000000000..96add8d148 --- /dev/null +++ b/app/src/main/res/xml/network_security_config_element.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + localhost + 127.0.0.1 + + 10.0.2.2 + + onion + + + + home.arpa + local + test + + home + lan + localdomain + + diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts index 64b9b76a14..e355499350 100644 --- a/appconfig/build.gradle.kts +++ b/appconfig/build.gradle.kts @@ -48,7 +48,6 @@ android { } dependencies { - implementation(libs.coroutines.core) implementation(libs.androidx.annotationjvm) implementation(libs.androidx.corektx) implementation(projects.libraries.matrix.api) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 12b95d9a09..cc11f5c033 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -44,12 +44,12 @@ 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) implementation(projects.features.announcement.api) + implementation(projects.features.callnative.api) implementation(projects.features.ftue.api) implementation(projects.features.linknewdevice.api) implementation(projects.features.share.api) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt index 1185016c0b..1b9d1b56cb 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/BackstackExt.kt @@ -8,9 +8,15 @@ package io.element.android.appnav +import com.bumble.appyx.core.navigation.NavKey import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.BackStackElement +import com.bumble.appyx.navmodel.backstack.BackStackElements +import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation import com.bumble.appyx.navmodel.backstack.operation.NewRoot import com.bumble.appyx.navmodel.backstack.operation.Remove +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue /** * Don't process NewRoot if the nav target already exists in the stack. @@ -32,3 +38,45 @@ fun BackStack.removeLast(element: T) { } ?: return accept(Remove(lastExpectedNavElement.key)) } + +/** + * Replace the root element of the backstack, and keep the elements on top of it, contrary to [safeRoot] which removes all the elements. + */ +fun BackStack.replaceRoot(element: T) { + accept(ReplaceRoot(element)) +} + +/** + * Replace the root element of the backstack, keeping the elements on top of it. + * + * Operation: + * + * [A, B, C] + ReplaceRoot(D) = [D, B, C] + * [A] + ReplaceRoot(D) = [D] + */ +@Parcelize +internal data class ReplaceRoot( + private val element: @RawValue T +) : BackStackOperation { + override fun isApplicable(elements: BackStackElements): Boolean = + elements.rootElement()?.key?.navTarget != element + + override fun invoke(elements: BackStackElements): BackStackElements { + val root = elements.rootElement() ?: return elements + if (root.targetState == BackStack.State.ACTIVE) { + // Nothing on top of the root, so this is the same as NewRoot + return NewRoot(element).invoke(elements) + } + // The root is stashed below other elements: drop it, as Remove does for a non active element, + // and add the new root, stashed, in place of it. + val newRoot = BackStackElement( + key = NavKey(element), + fromState = BackStack.State.STASHED, + targetState = BackStack.State.STASHED, + operation = this, + ) + return elements.map { if (it == root) newRoot else it } + } + + private fun BackStackElements.rootElement() = firstOrNull { it.targetState != BackStack.State.DESTROYED } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index 0dbcb0f3f1..90aaeadefc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,7 +33,8 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize /** @@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } return createNode(buildContext, listOf(callback)) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index cee2296d43..7c944764fd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -8,28 +8,51 @@ package io.element.android.appnav -import dev.zacsweers.metro.Inject +import androidx.annotation.VisibleForTesting +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange +import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -@Inject +@AssistedInject class LoggedInEventProcessor( - private val snackbarDispatcher: SnackbarDispatcher, + @Assisted private val snackbarDispatcher: SnackbarDispatcher, private val roomMembershipObserver: RoomMembershipObserver, + private val sessionId: SessionId, + private val syncService: SyncService, + private val notificationCleaner: NotificationCleaner, + private val dispatchers: CoroutineDispatchers ) { - private var observingJob: Job? = null + @AssistedFactory + interface Factory { + fun create(snackbarDispatcher: SnackbarDispatcher): LoggedInEventProcessor + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var currentChildScope: CoroutineScope? = null fun observeEvents(coroutineScope: CoroutineScope) { - observingJob = roomMembershipObserver.updates + if (currentChildScope != null) return + + val childScope = coroutineScope.childScope(dispatchers.computation, "LoggedInEventProcessor") + .also { currentChildScope = it } + + roomMembershipObserver.updates .filter { !it.isUserInRoom } .distinctUntilChanged() .onEach { roomMemberShipUpdate -> @@ -48,12 +71,19 @@ class LoggedInEventProcessor( else -> Unit } } - .launchIn(coroutineScope) + .launchIn(childScope) + + // Use the sync service heartbeat as a 'heartbeat' update to check existing notifications + syncService.heartBeat + .onEach { + notificationCleaner.clearReadRoomsNotifications(sessionId) + } + .launchIn(childScope) } fun stopObserving() { - observingJob?.cancel() - observingJob = null + currentChildScope?.cancel() + currentChildScope = null } private fun displayMessage(message: Int) { 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 a0bcf3b489..27d727afa9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -14,7 +14,6 @@ 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 @@ -48,16 +47,17 @@ import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.compound.colors.SemanticColorsLightDark +import io.element.android.features.callnative.api.NativeCallHost 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.CustomMapTilerConfigProvider 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.LocationService 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 @@ -66,8 +66,6 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint -import io.element.android.features.share.api.ShareEntryPoint -import io.element.android.features.share.api.ShareIntentData import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint @@ -87,12 +85,12 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +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.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData 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.verification.SessionVerificationServiceListener import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService @@ -110,14 +108,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.parcelize.Parcelize import timber.log.Timber -import java.time.Duration -import java.time.Instant import java.util.Optional import java.util.UUID import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -import kotlin.time.toKotlinDuration import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent // The maximum number of room nodes that should be kept in the backstack at the same time. @@ -141,9 +135,9 @@ class LoggedInFlowNode( private val sessionCoroutineScope: CoroutineScope, private val ftueService: FtueService, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, - private val shareEntryPoint: ShareEntryPoint, private val matrixClient: MatrixClient, private val sendingQueue: SendQueues, + private val nativeCallHost: NativeCallHost, private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, private val mediaPreviewConfigMigration: MediaPreviewConfigMigration, private val sessionEnterpriseService: SessionEnterpriseService, @@ -159,7 +153,8 @@ class LoggedInFlowNode( private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, + private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -175,44 +170,26 @@ class LoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() - private val loggedInFlowProcessor = LoggedInEventProcessor( - snackbarDispatcher = snackbarDispatcher, - roomMembershipObserver = matrixClient.roomMembershipObserver, - ) + private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher) - private val verificationListener = object : SessionVerificationServiceListener { - override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { - // Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash. - // This might be because this method is called back from Rust in a background thread. - lifecycleScope.launch { - val receivedAt = Instant.now() - - // Wait until the app is in foreground to display the incoming verification request - appNavigationStateService.appNavigationState.first { it.isInForeground } - - // TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet - val now = Instant.now() - val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration() - - // Discard the incoming verification request if it has timed out - if (elapsedTimeSinceReceived > 2.minutes) { - Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.") - return@launch + /** + * Display the incoming verification request, the app is expected to be in foreground. + */ + fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) { + lifecycleScope.launch { + // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it + // Otherwise, the RoomList UI may be incorrectly displayed on top + withTimeout(5.seconds) { + backstack.elements.first { elements -> + elements.any { it.key.navTarget == NavTarget.Home } } - - // Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it - // Otherwise, the RoomList UI may be incorrectly displayed on top - withTimeout(5.seconds) { - backstack.elements.first { elements -> - elements.any { it.key.navTarget == NavTarget.Home } - } - } - - backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest)) } + + backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest)) } } @@ -227,7 +204,6 @@ class LoggedInFlowNode( analyticsRoomListStateWatcher.start() appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId) loggedInFlowProcessor.observeEvents(sessionCoroutineScope) - matrixClient.sessionVerificationService.setListener(verificationListener) mediaPreviewConfigMigration() sessionCoroutineScope.launch { // Wait for the network to be connected before pre-fetching the max file upload size @@ -256,7 +232,6 @@ class LoggedInFlowNode( onDestroy = { appNavigationStateService.onLeavingSession(id) loggedInFlowProcessor.stopObserving() - matrixClient.sessionVerificationService.setListener(null) analyticsRoomListStateWatcher.stop() } ) @@ -317,9 +292,6 @@ class LoggedInFlowNode( @Parcelize data object RoomDirectory : NavTarget - @Parcelize - data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget - @Parcelize data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget } @@ -382,6 +354,14 @@ class LoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + override fun navigateToAddAccount() { + callback.navigateToAddAccount() + } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } homeEntryPoint.createNode( parentNode = this, @@ -474,6 +454,10 @@ class LoggedInFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToLinkNewDevice() { backstack.push(NavTarget.LinkNewDevice) } @@ -587,30 +571,6 @@ class LoggedInFlowNode( }, ) } - is NavTarget.IncomingShare -> { - shareEntryPoint.createNode( - parentNode = this, - buildContext = buildContext, - params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData), - callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) { - // Remove the incoming share screen - backstack.pop() - - // Navigate to the room if the text/media was shared to a single one - roomIds.singleOrNull()?.let { roomId -> - lifecycleScope.launch { - // Wait until the incoming share screen is removed - backstack.elements.first { it.lastOrNull()?.key?.navTarget !is NavTarget.IncomingShare } - - // Then attach the room - attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) - } - } - } - }, - ) - } is NavTarget.IncomingVerificationRequest -> { incomingVerificationEntryPoint.createNode( parentNode = this, @@ -626,6 +586,15 @@ class LoggedInFlowNode( } } + /** + * Wait for the home screen to be attached, i.e. for the FTUE to be complete. + */ + internal suspend fun waitForHome() { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.Home + } + } + suspend fun attachRoom( roomIdOrAlias: RoomIdOrAlias, serverNames: List = emptyList(), @@ -670,27 +639,13 @@ class LoggedInFlowNode( } } - internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) { - waitForNavTargetAttached { navTarget -> - navTarget is NavTarget.Home - } - attachChild { - backstack.push( - NavTarget.IncomingShare(shareIntentData) - ) - } - } - @Composable override fun View(modifier: Modifier) { val colors by remember { enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) }.collectAsState(SemanticColorsLightDark.default) - val currentMapTilerConfig = LocalMapTilerConfig.current - val updatedMapTilerConfig by produceState(currentMapTilerConfig) { - value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() ElementThemeApp( appPreferencesStore = appPreferencesStore, @@ -699,17 +654,21 @@ class LoggedInFlowNode( compoundDark = colors.dark, buildMeta = buildMeta, ) { - CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { + CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) { 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) + // Outside the connectivity indicator so the two strips stack rather than fight: an + // offline banner during a call belongs under the call bar, not over it. + nativeCallHost.Render(modifier = modifier) { callContentModifier -> + ConnectivityIndicatorContainer( + isOnline = isOnline, + modifier = callContentModifier, + ) { 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/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 080f3c1d77..c66ac97d83 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -13,6 +13,7 @@ package io.element.android.appnav import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi import com.bumble.appyx.core.lifecycle.subscribe @@ -24,6 +25,8 @@ 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.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.libraries.architecture.BackstackView @@ -33,8 +36,11 @@ import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices import io.element.android.libraries.designsystem.utils.ScreenOrientation -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(AppScope::class) @@ -45,6 +51,8 @@ class NotLoggedInFlowNode( private val loginEntryPoint: LoginEntryPoint, private val imageLoaderHolder: ImageLoaderHolder, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val featureFlagService: FeatureFlagService, + private val announcementService: AnnouncementService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -68,6 +76,13 @@ class NotLoggedInFlowNode( override fun onBuilt() { super.onBuilt() analyticsColdStartWatcher.whenLoggingIn() + lifecycleScope.launch { + // Users who enter the login flow while the feature is available, either for the first time or to add + // another account, do not need to be informed about the multi-account feature. + if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } lifecycle.subscribe( onResume = { SingletonImageLoader.setUnsafe(imageLoaderHolder.get()) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5f2dc837ad..5fb7996a0e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -40,10 +40,15 @@ import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.root.RootView import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.appnav.verification.IncomingVerificationRequestData +import io.element.android.appnav.verification.IncomingVerificationRequestObserver +import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode +import io.element.android.appnav.verification.OtherSessionVerificationNode import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.login.api.LoginParams import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint +import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData import io.element.android.features.signedout.api.SignedOutEntryPoint import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint @@ -52,17 +57,20 @@ import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.waitForChildAttached +import io.element.android.libraries.core.coroutine.withPreviousValue import io.element.android.libraries.deeplink.api.DeeplinkData 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.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.oauth.api.OAuthAction import io.element.android.libraries.oauth.api.OAuthActionFlow import io.element.android.libraries.sessionstorage.api.LoggedInState @@ -75,11 +83,16 @@ import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.parcelize.Parcelize import timber.log.Timber +import kotlin.time.Duration.Companion.seconds @ContributesNode(AppScope::class) @AssistedInject @@ -100,6 +113,8 @@ class RootFlowNode( private val announcementService: AnnouncementService, private val analyticsService: AnalyticsService, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver, + private val shareEntryPoint: ShareEntryPoint, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : BaseFlowNode( backstack = BackStack( @@ -127,6 +142,8 @@ class RootFlowNode( observeNavState(false) } } + observeIncomingVerificationRequests() + observeRemovedSessions() super.onBuilt() } @@ -202,7 +219,68 @@ class RootFlowNode( private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { pendingLoginParams = null - backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) + setRoot(NavTarget.LoggedInFlow(sessionId, navId)) + restoreAllSessions() + } + + /** + * Restore all the sessions with a valid token, so that they sync and can receive incoming verification requests. + */ + private fun restoreAllSessions() = lifecycleScope.launch { + sessionStore.getAllSessions() + .filter { it.isTokenValid } + .forEach { sessionData -> + matrixSessionCache.getOrRestore(SessionId(sessionData.userId)).onFailure { + Timber.e(it, "Failed to restore session ${sessionData.userId}") + } + } + } + + /** + * Remove from the cache the sessions which have been removed from the session store, for instance after a logout. + */ + private fun observeRemovedSessions() { + sessionStore.sessionsFlow() + .map { sessions -> sessions.map { SessionId(it.userId) }.toSet() } + .withPreviousValue() + .onEach { (previous, current) -> + (previous.orEmpty() - current).forEach { sessionId -> + // The verification of a removed session cannot continue, for instance if the user has signed out from it + backstack.removeLast(NavTarget.OtherAccountSessionVerification(sessionId)) + Timber.d("Session $sessionId removed, remove it from the cache") + matrixSessionCache.remove(sessionId) + } + } + .launchIn(lifecycleScope) + } + + private fun observeIncomingVerificationRequests() { + incomingVerificationRequestObserver.incomingVerificationRequests() + .onEach(::onIncomingVerificationRequest) + .launchIn(lifecycleScope) + } + + private fun onIncomingVerificationRequest(data: IncomingVerificationRequestData) = lifecycleScope.launch { + // Wait for a logged in flow to be displayed + val currentSessionId = withTimeoutOrNull(5.seconds) { + backstack.elements.map { elements -> + elements.firstNotNullOfOrNull { (it.key.navTarget as? NavTarget.LoggedInFlow)?.sessionId } + }.filterNotNull().first() + } + if (currentSessionId == null) { + Timber.w("Incoming verification request ${data.verificationRequest.details.flowId} discarded, no logged in flow.") + return@launch + } + if (currentSessionId == data.sessionId) { + waitForChildAttached { navTarget -> + navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == currentSessionId + } + .attachSession() + .onIncomingVerificationRequest(data.verificationRequest) + } else { + // Display the request on top of the current session, without switching session + backstack.push(NavTarget.OtherAccountIncomingVerificationRequest(data.sessionId, data.verificationRequest)) + } } private fun switchToNotLoggedInFlow(params: LoginParams?) { @@ -213,7 +291,20 @@ class RootFlowNode( private fun switchToSignedOutFlow(sessionId: SessionId) { pendingLoginParams = null - backstack.safeRoot(NavTarget.SignedOutFlow(sessionId)) + setRoot(NavTarget.SignedOutFlow(sessionId)) + } + + /** + * Set the root of the backstack. If an incoming share is displayed, keep it on top of the new root, so that the user can + * finish sharing, for instance with another account if the current one has been signed out. + */ + private fun setRoot(navTarget: NavTarget) { + val hasIncomingShare = backstack.elements.value.any { it.key.navTarget is NavTarget.IncomingShare } + if (hasIncomingShare) { + backstack.replaceRoot(navTarget) + } else { + backstack.safeRoot(navTarget) + } } private suspend fun restoreSessionIfNeeded( @@ -277,8 +368,7 @@ class RootFlowNode( @Parcelize data class AccountSelect( val currentSessionId: SessionId, - val shareIntentData: ShareIntentData?, - val permalinkData: PermalinkData?, + val permalinkData: PermalinkData, ) : NavTarget @Parcelize data class NotLoggedInFlow( @@ -294,6 +384,20 @@ class RootFlowNode( ) : NavTarget @Parcelize data object BugReport : NavTarget + + @Parcelize data class IncomingShare( + val sessionId: SessionId, + val shareIntentData: ShareIntentData, + ) : NavTarget + + @Parcelize data class OtherAccountIncomingVerificationRequest( + val sessionId: SessionId, + val verificationRequest: VerificationRequest.Incoming, + ) : NavTarget + + @Parcelize data class OtherAccountSessionVerification( + val sessionId: SessionId, + ) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -312,6 +416,12 @@ class RootFlowNode( override fun navigateToAddAccount() { backstack.push(NavTarget.NotLoggedInFlow(null)) } + + override fun navigateToSession(sessionId: SessionId) { + lifecycleScope.launch { + sessionStore.setLatestSession(sessionId.value) + } + } } val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val buildContext = if (savedNavState != null) { @@ -348,6 +458,73 @@ class RootFlowNode( ) } NavTarget.SplashScreen -> emptyNode(buildContext) + is NavTarget.OtherAccountIncomingVerificationRequest -> { + if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) { + Timber.w("Couldn't find session ${navTarget.sessionId} for the incoming verification request") + lifecycleScope.launch { backstack.pop() } + return emptyNode(buildContext) + } + val inputs = OtherSessionIncomingVerificationNode.Inputs( + sessionId = navTarget.sessionId, + verificationRequest = navTarget.verificationRequest, + ) + val callback = object : OtherSessionIncomingVerificationNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + is NavTarget.OtherAccountSessionVerification -> { + if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) { + Timber.w("Couldn't find session ${navTarget.sessionId} to verify") + lifecycleScope.launch { backstack.pop() } + return emptyNode(buildContext) + } + val inputs = OtherSessionVerificationNode.Inputs(sessionId = navTarget.sessionId) + val callback = object : OtherSessionVerificationNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, plugins = listOf(inputs, callback)) + } + is NavTarget.IncomingShare -> { + val callback = object : ShareEntryPoint.Callback { + override fun onDone(sessionId: SessionId, roomIds: List) { + // Remove the incoming share screen + backstack.pop() + lifecycleScope.launch { + // Switch to the session which has been used to share the data, if necessary, + // and navigate to the room if the data was shared to a single one. + val loggedInFlowNode = attachSession(sessionId) + roomIds.singleOrNull()?.let { roomId -> + loggedInFlowNode.attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false) + } + } + } + + override fun onCancel() { + backstack.pop() + } + + override fun onSessionVerificationRequired(sessionId: SessionId) { + val navTarget = NavTarget.OtherAccountSessionVerification(sessionId) + if (backstack.elements.value.lastOrNull()?.key?.navTarget != navTarget) { + backstack.push(navTarget) + } + } + } + shareEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = ShareEntryPoint.Params( + sessionId = navTarget.sessionId, + shareIntentData = navTarget.shareIntentData, + ), + callback = callback, + ) + } NavTarget.BugReport -> { val callback = object : BugReportEntryPoint.Callback { override fun onDone() { @@ -369,13 +546,7 @@ class RootFlowNode( // Do not pop when the account is changed to avoid a UI flicker. backstack.pop() } - attachSession(sessionId).apply { - if (navTarget.shareIntentData != null) { - attachIncomingShare(navTarget.shareIntentData) - } else if (navTarget.permalinkData != null) { - attachPermalinkData(navTarget.permalinkData) - } - } + attachSession(sessionId).attachPermalinkData(navTarget.permalinkData) } } @@ -445,21 +616,12 @@ class RootFlowNode( // No session, open login switchToNotLoggedInFlow(null) } else { - // wait for the current session to be restored - val loggedInFlowNode = attachSession(latestSessionId) - if (sessionStore.numberOfSessions() > 1) { - // Several accounts, let the user choose which one to use - backstack.push( - NavTarget.AccountSelect( - currentSessionId = latestSessionId, - shareIntentData = shareIntentData, - permalinkData = null, - ) - ) - } else { - // Only one account, directly attach the incoming share node. - loggedInFlowNode.attachIncomingShare(shareIntentData) - } + // Wait for the current session to be restored and for the FTUE to be complete, so that the session is verified, + // then display the incoming share on top of it. + // The incoming share does not belong to the current session: in case of multiple accounts, the user can switch account + // from the room select screen, and the share is kept if the current session is signed out in the meantime. + attachSession(latestSessionId).waitForHome() + backstack.push(NavTarget.IncomingShare(latestSessionId, shareIntentData)) } } @@ -482,7 +644,6 @@ class RootFlowNode( backstack.push( NavTarget.AccountSelect( currentSessionId = latestSessionId, - shareIntentData = null, permalinkData = permalinkData, ) ) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt index 3f01f8337e..177807d501 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/SendQueues.kt @@ -41,7 +41,7 @@ class SendQueues( fun launchIn(coroutineScope: CoroutineScope) { combine( syncService.syncState, - matrixClient.sendQueueDisabledFlow(), + matrixClient.sendQueueDisabledFlow().onEach { Timber.tag("SendQueues").d("Send queue disabled for room: $it") }, ) { syncState, _ -> syncState } .debounce(SEND_QUEUES_RETRY_DELAY_MILLIS) .onEach { syncState -> diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 7080b34f8e..201a4adab0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -28,8 +28,8 @@ import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.LoadingRoomNodeView import io.element.android.features.joinroom.api.JoinRoomEntryPoint -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint.Params import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt index 0c9abbc571..d74b11be0c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.appnav.room.joined import android.os.Parcelable -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index dbe53b75ac..7205d095b4 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -201,7 +201,7 @@ class JoinedRoomLoadedFlowNode( } else { (graph as TimelineBindings).timelineProvider } - val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) + val params = ForwardEntryPoint.Params(listOf(navTarget.eventId), timelineProvider) val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) { backstack.pop() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt index 175b662e08..2a2e3f5b91 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize 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 @@ -68,7 +67,6 @@ fun LoadingRoomNodeView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun LoadingRoomTopBar( onBackClick: () -> Unit, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt index 8b02752a64..17d6826a6c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootNavStateFlowFactory.kt @@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.Inject import io.element.android.appnav.session.MatrixSessionCache import io.element.android.features.preferences.api.CacheService -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.Flow diff --git a/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt index ffae5f2b17..20d7bad228 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/session/MatrixSessionCache.kt @@ -21,6 +21,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.AnalyticsUserData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -41,6 +44,12 @@ class MatrixSessionCache( private val analyticsService: AnalyticsService, ) : MatrixClientProvider { private val sessionIdsToMatrixSession = ConcurrentHashMap() + private val _matrixClients = MutableStateFlow>(emptyList()) + + /** + * The [MatrixClient]s currently in the cache. + */ + val matrixClients: StateFlow> = _matrixClients.asStateFlow() private val restoreMutex = Mutex() init { @@ -51,10 +60,12 @@ class MatrixSessionCache( fun removeAll() { sessionIdsToMatrixSession.clear() + updateMatrixClients() } fun remove(sessionId: SessionId) { sessionIdsToMatrixSession.remove(sessionId) + updateMatrixClients() } override fun getOrNull(sessionId: SessionId): MatrixClient? { @@ -122,8 +133,13 @@ class MatrixSessionCache( matrixClient = matrixClient, syncOrchestrator = syncOrchestrator, ) + updateMatrixClients() syncOrchestrator.start() } + + private fun updateMatrixClients() { + _matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient } + } } private data class InMemoryMatrixSession( diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt new file mode 100644 index 0000000000..bfc952334a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserver.kt @@ -0,0 +1,81 @@ +/* + * 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.appnav.verification + +import dev.zacsweers.metro.Inject +import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.libraries.core.coroutine.withPreviousValue +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.minutes + +data class IncomingVerificationRequestData( + val sessionId: SessionId, + val verificationRequest: VerificationRequest.Incoming, +) + +/** + * Listens to the incoming verification requests of all the sessions in [MatrixSessionCache], not only the current one. + */ +@Inject +class IncomingVerificationRequestObserver( + private val matrixSessionCache: MatrixSessionCache, + private val appForegroundStateService: AppForegroundStateService, +) { + /** + * Emits the incoming verification requests once the app is in foreground. + * Requests received while the app is in background for too long are discarded. + */ + fun incomingVerificationRequests(): Flow = channelFlow { + launch { + matrixSessionCache.matrixClients.withPreviousValue().collect { (previousMatrixClients, matrixClients) -> + val previousMatrixClientSet = previousMatrixClients.orEmpty().toSet() + // Remove the listener of the clients which are not in the cache anymore + (previousMatrixClientSet - matrixClients.toSet()).forEach { matrixClient -> + matrixClient.sessionVerificationService.setListener(null) + } + (matrixClients - previousMatrixClientSet).forEach { matrixClient -> + val sessionId = matrixClient.sessionId + matrixClient.sessionVerificationService.setListener( + object : SessionVerificationServiceListener { + override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) { + launch { + // Wait until the app is in foreground to display the incoming verification request + // TODO there should also be a timeout for > 10 minutes elapsed since the request was created, + // but the SDK doesn't expose that info yet + val isInForeground = withTimeoutOrNull(2.minutes) { + appForegroundStateService.isInForeground.first { it } + } + if (isInForeground == null) { + Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.") + } else { + send(IncomingVerificationRequestData(sessionId, verificationRequest)) + } + } + } + } + ) + } + } + } + awaitClose { + matrixSessionCache.matrixClients.value.forEach { matrixClient -> + matrixClient.sessionVerificationService.setListener(null) + } + } + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.kt new file mode 100644 index 0000000000..115c174bb8 --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionIncomingVerificationNode.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.appnav.verification + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +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.appnav.di.SessionGraphFactory +import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.VerificationRequest +import kotlinx.parcelize.Parcelize + +/** + * Display an incoming verification request received by a session which is not the current one, without switching the current session. + * It sets up the Session graph of the session receiving the request. + */ +@ContributesNode(AppScope::class) +@AssistedInject +class OtherSessionIncomingVerificationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + sessionGraphFactory: SessionGraphFactory, + matrixClientProvider: MatrixClientProvider, + private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +), DependencyInjectionGraphOwner { + interface Callback : Plugin { + fun onDone() + } + + @Parcelize + object NavTarget : Parcelable + + data class Inputs( + val sessionId: SessionId, + val verificationRequest: VerificationRequest.Incoming, + ) : NodeInputs + + private val callback: Callback = callback() + private val inputs: Inputs = inputs() + override val graph = sessionGraphFactory.create( + requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" } + ) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return incomingVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + params = IncomingVerificationEntryPoint.Params(inputs.verificationRequest), + callback = object : IncomingVerificationEntryPoint.Callback { + override fun onDone() { + callback.onDone() + } + }, + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = navModel, + modifier = modifier, + ) + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.kt new file mode 100644 index 0000000000..1b6078888a --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/verification/OtherSessionVerificationNode.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. + */ + +package io.element.android.appnav.verification + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +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.appnav.di.SessionGraphFactory +import io.element.android.features.ftue.api.SessionVerificationEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.DependencyInjectionGraphOwner +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.parcelize.Parcelize + +/** + * Display the verification of a session which is not the current one, without switching the current session. + * It sets up the Session graph of the session to verify. + */ +@ContributesNode(AppScope::class) +@AssistedInject +class OtherSessionVerificationNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + sessionGraphFactory: SessionGraphFactory, + matrixClientProvider: MatrixClientProvider, + private val sessionVerificationEntryPoint: SessionVerificationEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins +), DependencyInjectionGraphOwner { + interface Callback : Plugin { + fun onDone() + } + + @Parcelize + object NavTarget : Parcelable + + data class Inputs( + val sessionId: SessionId, + ) : NodeInputs + + private val callback: Callback = callback() + private val inputs: Inputs = inputs() + override val graph = sessionGraphFactory.create( + requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" } + ) + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return sessionVerificationEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = object : SessionVerificationEntryPoint.Callback { + override fun onDone() { + callback.onDone() + } + }, + ) + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = navModel, + modifier = modifier, + ) + } +} diff --git a/appnav/src/main/res/values-cs/translations.xml b/appnav/src/main/res/values-cs/translations.xml index 3652bda56f..7b4a58d394 100644 --- a/appnav/src/main/res/values-cs/translations.xml +++ b/appnav/src/main/res/values-cs/translations.xml @@ -2,5 +2,5 @@ "Odhlásit se a upgradovat" "%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace." - "Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste." + "Váš poskytovatel účtu již nepodporuje starý protokol. Chcete-li aplikaci nadále používat, odhlaste se a znovu se přihlaste." diff --git a/appnav/src/main/res/values-ro/translations.xml b/appnav/src/main/res/values-ro/translations.xml index 626902b490..79be68b3bc 100644 --- a/appnav/src/main/res/values-ro/translations.xml +++ b/appnav/src/main/res/values-ro/translations.xml @@ -2,5 +2,5 @@ "Deconectați-vă și faceți upgrade" "%1$s nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă reconectați pentru a continua utilizarea aplicației." - "Serverul dvs. de acasă nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă conectați din nou pentru a continua să utilizați aplicația." + "Furnizorul contului dvs. nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă conectați din nou pentru a continua să utilizați aplicația." diff --git a/appnav/src/test/kotlin/io/element/android/appnav/BackstackExtTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/BackstackExtTest.kt new file mode 100644 index 0000000000..87173022a1 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/BackstackExtTest.kt @@ -0,0 +1,56 @@ +/* + * 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.appnav + +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class BackstackExtTest { + @Test + fun `replaceRoot without element on top replaces the root`() { + val backStack = BackStack(initialElement = "A", savedStateMap = null) + backStack.replaceRoot("D") + assertThat(backStack.navTargets()).containsExactly("D") + assertThat(backStack.activeTarget()).isEqualTo("D") + } + + @Test + fun `replaceRoot with elements on top replaces the root and keeps the elements on top`() { + val backStack = BackStack(initialElement = "A", savedStateMap = null) + backStack.push("B") + backStack.push("C") + backStack.replaceRoot("D") + assertThat(backStack.navTargets()).containsExactly("D", "B", "C").inOrder() + assertThat(backStack.activeTarget()).isEqualTo("C") + // When the elements on top are removed, the new root is displayed + backStack.pop() + backStack.pop() + assertThat(backStack.activeTarget()).isEqualTo("D") + } + + @Test + fun `replaceRoot with the current root does nothing`() { + val backStack = BackStack(initialElement = "A", savedStateMap = null) + backStack.push("B") + backStack.replaceRoot("A") + assertThat(backStack.navTargets()).containsExactly("A", "B").inOrder() + assertThat(backStack.activeTarget()).isEqualTo("B") + } + + private fun BackStack.navTargets() = elements.value + .filter { it.targetState != BackStack.State.DESTROYED } + .map { it.key.navTarget } + + private fun BackStack.activeTarget() = elements.value + .single { it.targetState == BackStack.State.ACTIVE } + .key + .navTarget +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt new file mode 100644 index 0000000000..fd1f1b9d11 --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt @@ -0,0 +1,196 @@ +/* + * 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.appnav + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.sync.FakeSyncService +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +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.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoggedInEventProcessorTest { + @Test + fun `test observeEvents with left membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with left membership change for space`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = true, + membershipBeforeLeft = CurrentUserMembership.JOINED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with invitation rejected membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.INVITED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test observeEvents with knock retracted membership change`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + roomMembershipObserver.notifyUserLeftRoom( + roomId = A_ROOM_ID, + isSpace = false, + membershipBeforeLeft = CurrentUserMembership.KNOCKED, + ) + + testScheduler.runCurrent() + + // Verify that the snackbar message was displayed + assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull() + } + + @Test + fun `test stopObserving cancels the child scope`() = runTest { + val snackbarDispatcher = SnackbarDispatcher() + val roomMembershipObserver = RoomMembershipObserver() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + assertThat(loggedInEventProcessor.currentChildScope?.isActive).isTrue() + + testScheduler.runCurrent() + + // Stop observing events + loggedInEventProcessor.stopObserving() + + // Verify that the child scope is cancelled (the scope is null) + assertThat(loggedInEventProcessor.currentChildScope).isNull() + } + + @Test + fun `test notifications are cleared when sync heartbeat is emitted`() = runTest { + val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } + val snackbarDispatcher = SnackbarDispatcher() + val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) + val syncService = FakeSyncService() + + val loggedInEventProcessor = createLoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + notificationCleaner = notificationCleaner, + syncService = syncService, + ) + + loggedInEventProcessor.observeEvents(backgroundScope) + + testScheduler.runCurrent() + + // Simulate a sync heartbeat + syncService.emitHeartBeat() + + testScheduler.runCurrent() + + // Verify that the notifications were cleared + clearReadNotificationsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + private fun TestScope.createLoggedInEventProcessor( + snackbarDispatcher: SnackbarDispatcher, + roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), + sessionId: SessionId = A_SESSION_ID, + syncService: FakeSyncService = FakeSyncService(), + notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), + ) = LoggedInEventProcessor( + snackbarDispatcher = snackbarDispatcher, + roomMembershipObserver = roomMembershipObserver, + sessionId = sessionId, + syncService = syncService, + notificationCleaner = notificationCleaner, + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt index fea30c8019..d9b48c2ade 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootFlowNodeTest.kt @@ -14,6 +14,7 @@ import android.net.Uri import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.AncestryInfo import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.node import com.bumble.appyx.navmodel.backstack.activeElement import com.bumble.appyx.testing.junit4.util.MainDispatcherRule @@ -25,6 +26,8 @@ import io.element.android.appnav.root.RootNavStateFlowFactory import io.element.android.appnav.root.RootPresenter import io.element.android.appnav.session.FakeSyncOrchestratorFactory import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.appnav.verification.IncomingVerificationRequestObserver +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginParams import io.element.android.features.login.test.FakeLoginEntryPoint @@ -32,7 +35,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl import io.element.android.features.preferences.test.FakeCacheService import io.element.android.features.rageshake.test.FakeBugReportEntryPoint -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.test.FakeShareIntentHandler import io.element.android.features.signedout.test.FakeSignedOutEntryPoint import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint @@ -41,7 +44,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.oauth.test.FakeOAuthActionFlow import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory @@ -50,6 +53,8 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.apperror.test.FakeAppErrorStateService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings import io.element.android.tests.testutils.node.FakeParentNode import io.element.android.tests.testutils.presenter.NotUsedPresenter @@ -139,6 +144,8 @@ class RootFlowNodeTest : RobolectricTest() { loginEntryPoint = loginEntryPoint, imageLoaderHolder = FakeImageLoaderHolder(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + featureFlagService = FakeFeatureFlagService(), + announcementService = FakeAnnouncementService(), ) } ) @@ -183,7 +190,19 @@ class RootFlowNodeTest : RobolectricTest() { announcementService = FakeAnnouncementService(), analyticsService = FakeAnalyticsService(), analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(), + incomingVerificationRequestObserver = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ), appCoroutineScope = backgroundScope, + shareEntryPoint = object : ShareEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: ShareEntryPoint.Params, + callback: ShareEntryPoint.Callback, + ): Node = lambdaError() + }, ) } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt index bf315ddb01..8d4a162d4f 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/session/MatrixSessionCacheTest.kt @@ -70,9 +70,11 @@ class MatrixSessionCacheTest { fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient) // Remove matrixSessionCache.remove(A_SESSION_ID) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + assertThat(matrixSessionCache.matrixClients.value).isEmpty() } @Test @@ -83,9 +85,11 @@ class MatrixSessionCacheTest { fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) + assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient) // Remove all matrixSessionCache.removeAll() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() + assertThat(matrixSessionCache.matrixClients.value).isEmpty() } @Test diff --git a/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt new file mode 100644 index 0000000000..35946ea82d --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/verification/IncomingVerificationRequestObserverTest.kt @@ -0,0 +1,191 @@ +/* + * 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.appnav.verification + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.appnav.session.MatrixSessionCache +import io.element.android.appnav.session.SyncOrchestrator +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.core.FlowId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails +import io.element.android.libraries.matrix.api.verification.VerificationRequest +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_USER_ID_3 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class IncomingVerificationRequestObserverTest { + private val sessionVerificationService1 = FakeSessionVerificationService() + private val sessionVerificationService2 = FakeSessionVerificationService() + + @Test + fun `incoming verification requests of all the sessions are emitted with their session id`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + matrixSessionCache.getOrRestore(A_SESSION_ID_2) + runCurrent() + val request = anIncomingVerificationRequest() + sessionVerificationService2.listener!!.onIncomingSessionRequest(request) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request)) + sessionVerificationService1.listener!!.onIncomingSessionRequest(request) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request)) + } + // The listeners are removed when the flow is not collected anymore + assertThat(sessionVerificationService1.listener).isNull() + assertThat(sessionVerificationService2.listener).isNull() + } + + @Test + fun `listener is removed when the session is removed from the cache`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + matrixSessionCache.getOrRestore(A_SESSION_ID_2) + runCurrent() + assertThat(sessionVerificationService1.listener).isNotNull() + assertThat(sessionVerificationService2.listener).isNotNull() + matrixSessionCache.remove(A_SESSION_ID) + runCurrent() + assertThat(sessionVerificationService1.listener).isNull() + assertThat(sessionVerificationService2.listener).isNotNull() + } + } + + @Test + fun `incoming user verification request of another session is emitted with its session id`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = FakeAppForegroundStateService(), + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + matrixSessionCache.getOrRestore(A_SESSION_ID_2) + runCurrent() + val request = anIncomingUserVerificationRequest() + sessionVerificationService2.listener!!.onIncomingSessionRequest(request) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request)) + } + } + + @Test + fun `incoming verification request is emitted when the app goes to foreground`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false) + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = appForegroundStateService, + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + runCurrent() + val request = anIncomingVerificationRequest() + sessionVerificationService1.listener!!.onIncomingSessionRequest(request) + advanceTimeBy(30.seconds) + expectNoEvents() + appForegroundStateService.givenIsInForeground(true) + assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request)) + } + } + + @Test + fun `incoming verification request is discarded if the app does not go to foreground in time`() = runTest { + val matrixSessionCache = createMatrixSessionCache() + val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false) + val observer = IncomingVerificationRequestObserver( + matrixSessionCache = matrixSessionCache, + appForegroundStateService = appForegroundStateService, + ) + observer.incomingVerificationRequests().test { + matrixSessionCache.getOrRestore(A_SESSION_ID) + runCurrent() + sessionVerificationService1.listener!!.onIncomingSessionRequest(anIncomingVerificationRequest()) + advanceTimeBy(3.minutes) + appForegroundStateService.givenIsInForeground(true) + runCurrent() + expectNoEvents() + } + } + + private fun TestScope.createMatrixSessionCache(): MatrixSessionCache { + val matrixClient1 = FakeMatrixClient( + sessionId = A_SESSION_ID, + sessionCoroutineScope = backgroundScope, + sessionVerificationService = sessionVerificationService1, + userIdServerNameLambda = { "server" }, + ) + val matrixClient2 = FakeMatrixClient( + sessionId = A_SESSION_ID_2, + sessionCoroutineScope = backgroundScope, + sessionVerificationService = sessionVerificationService2, + userIdServerNameLambda = { "server" }, + ) + return MatrixSessionCache( + authenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { sessionId -> + Result.success(if (sessionId == A_SESSION_ID) matrixClient1 else matrixClient2) + }, + ), + syncOrchestratorFactory = object : SyncOrchestrator.Factory { + override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope) = SyncOrchestrator( + matrixClient = matrixClient, + sessionCoroutineScope = sessionCoroutineScope, + appForegroundStateService = FakeAppForegroundStateService(), + networkMonitor = FakeNetworkMonitor(), + dispatchers = testCoroutineDispatchers(), + analyticsService = FakeAnalyticsService(), + ) + }, + analyticsService = FakeAnalyticsService(), + ) + } + + private fun anIncomingVerificationRequest() = VerificationRequest.Incoming.OtherSession( + details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_SESSION_ID)), + ) + + private fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User( + details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_USER_ID_3)), + ) + + private fun aSessionVerificationRequestDetails(senderProfile: MatrixUser) = SessionVerificationRequestDetails( + senderProfile = senderProfile, + flowId = FlowId("flowId"), + deviceId = DeviceId("deviceId"), + deviceDisplayName = "a device name", + firstSeenTimestamp = 0, + ) +} diff --git a/build.gradle.kts b/build.gradle.kts index e7a03b403d..a5af43c743 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -101,10 +101,6 @@ allprojects { // Uncomment to suppress Compose Kotlin compiler compatibility warning // freeCompilerArgs.addAll(listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true")) - - // Fix compilation warning for annotations - // See https://youtrack.jetbrains.com/issue/KT-73255/Change-defaulting-rule-for-annotations for more details - freeCompilerArgs.add("-Xannotation-default-target=first-only") } } } @@ -149,7 +145,13 @@ sonar { allprojects { tasks.withType { - maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + // Robolectric 4.17 sets up shared memory during application setup, which goes through its + // FileDescriptor interceptor. That interceptor reaches into `java.io.FileDescriptor` and + // `jdk.internal.access.SharedSecrets`, both of which are encapsulated since JDK 17. + jvmArgs( + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", + ) val isScreenshotTest = project.gradle.startParameter.taskNames.any { it.contains("paparazzi", ignoreCase = true) } if (isScreenshotTest) { @@ -173,6 +175,9 @@ allprojects { // Gradle parallelise by running many modules' test tasks concurrently instead. maxParallelForks = 1 + // Increase heap size for screenshot tests + maxHeapSize = "2g" + // Disable screenshot tests by default exclude("ui/*.class") exclude("translations/*.class") @@ -211,19 +216,93 @@ tasks.register("generateDocsToc", Exec::class.java) { commandLine("python3", "tools/docs/generate_toc.py", *inputs.files.map { it.path }.toTypedArray()) } -// Make sure to delete old screenshots before recording new ones +// Delete all the screenshots. Not hooked to the record tasks on purpose, see `pruneObsoleteSnapshots` +// below. Run it by hand (or use Paparazzi's own `cleanRecordPaparazziDebug`) to record from scratch. subprojects { val snapshotsDir = File("${project.projectDir}/src/test/snapshots") - val removeOldScreenshotsTask = tasks.register("removeOldSnapshots") { + tasks.register("removeOldSnapshots") { onlyIf { snapshotsDir.exists() } doFirst { println("Delete previous screenshots located at $snapshotsDir\n") snapshotsDir.deleteRecursively() } } - tasks.findByName("recordPaparazzi")?.dependsOn(removeOldScreenshotsTask) - tasks.findByName("recordPaparazziDebug")?.dependsOn(removeOldScreenshotsTask) - tasks.findByName("recordPaparazziRelease")?.dependsOn(removeOldScreenshotsTask) +} + +// Delete the screenshots of previews that no longer exist, *after* recording. +// +// Wiping the whole snapshots directory before recording used to be how stale screenshots were removed, +// but it also removes the reference image Paparazzi needs to honour +// `app.cash.paparazzi.overwriteOnMaxPercentDifference` (see gradle.properties): with no existing file to +// compare against, every screenshot is rewritten unconditionally, so renderer noise far below the +// threshold `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot. +// +// Instead, let the record task keep the existing screenshots, then prune the ones belonging to a +// preview that Paparazzi did not render at all. +// +// Pruning per preview, and not per screenshot, is deliberate: a preview that fails to render is absent +// from the run report in exactly the same way as a preview that was deleted, and LayoutLib does fail to +// render a preview now and then. Matching whole previews means such +// a failure leaves the other screenshots of that preview in place, so it cannot silently delete a +// screenshot that is still in use. The cost is that dropping one value from a PreviewParameterProvider +// leaves its last screenshot behind, since the preview itself still renders. Run `removeOldSnapshots` +// and record again to clean those up. +subprojects { + val projectDir = project.projectDir + val pruneObsoleteSnapshots = tasks.register("pruneObsoleteSnapshots") { + description = "Delete the recorded screenshots of previews that no longer exist" + val imagesDir = File(projectDir, "src/test/snapshots/images") + val reportsDir = File(projectDir, "build/reports/paparazzi") + onlyIf { imagesDir.exists() } + doLast { + // One `runs/.js` per forked test JVM, each holding `window.runs[...] = [ ...snapshots... ];`. + val runFiles = reportsDir.walkTopDown() + .filter { it.isFile && it.extension == "js" && it.parentFile.name == "runs" } + .toList() + check(runFiles.isNotEmpty()) { + "No Paparazzi run report found under $reportsDir, so there is no way to tell which " + + "screenshots are still in use. Refusing to delete anything." + } + // `testName` is serialised as ".#" and the screenshot file is named + // "__.png". Only the package and the class are used here, they identify + // the preview. Do not be tempted to rebuild the whole file name from the method: it has to be + // normalised exactly as `Snapshot.toFileName` does, which replaces whitespace with the + // delimiter, so a preview named "List item - Simple" does not read as its own file name. + val testNameRegex = """"testName"\s*:\s*"(.*)\.([^.]*)#""".toRegex() + val renderedPreviews = runFiles.flatMap { runFile -> + testNameRegex.findAll(runFile.readText()).map { match -> + val (packageName, className) = match.destructured + // Keep the trailing delimiter, so that `Foo_` does not also match `FooBar_…`. + "${packageName}_${className}_" + } + }.toSet() + check(renderedPreviews.isNotEmpty()) { + "Could not read any snapshot name out of the Paparazzi run reports in $reportsDir. " + + "The report format has probably changed. Refusing to delete anything." + } + + val existing = imagesDir.listFiles { file -> file.extension == "png" }.orEmpty() + val obsolete = existing.filter { file -> renderedPreviews.none { file.name.startsWith(it) } } + // Deleting a screenshot is a tracked file deletion, and a bad `renderedPreviews` set would wipe + // thousands of them. Anything beyond a trickle means something is wrong rather than a few + // deleted previews. + check(obsolete.size <= existing.size / 10) { + "pruneObsoleteSnapshots would delete ${obsolete.size} of ${existing.size} screenshots in " + + "$imagesDir, which looks like a bug rather than deleted previews. Refusing to delete " + + "anything. If the deletions are expected, run `removeOldSnapshots` and record again." + } + obsolete.forEach { + println("Delete obsolete screenshot ${it.name}") + it.delete() + } + println("Pruned ${obsolete.size} obsolete screenshot(s), kept ${existing.size - obsolete.size}.\n") + } + } + // `configureEach` on a live filtered collection, and not `findByName`, because the Paparazzi plugin + // registers its record tasks per variant, long after this block runs. + tasks.matching { it.name.startsWith("recordPaparazzi") }.configureEach { + finalizedBy(pruneObsoleteSnapshots) + } } subprojects { diff --git a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt index 3e6c05ad01..854f327665 100644 --- a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt +++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt @@ -53,7 +53,7 @@ class ContributesNodeProcessor( val annotatedSymbols = resolver.getSymbolsWithAnnotation(ContributesNode::class.qualifiedName!!) .filterIsInstance() - val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate() } + val (validSymbols, invalidSymbols) = annotatedSymbols.partition { it.validate(enableNewFeatures = false) } if (validSymbols.isEmpty()) return invalidSymbols diff --git a/docs/nightly_build.md b/docs/nightly_build.md index 3a3345d662..76fdfeb0ef 100644 --- a/docs/nightly_build.md +++ b/docs/nightly_build.md @@ -27,6 +27,7 @@ Click on this link and follow the instruction: [https://appdistribution.firebase Nightly build can be built manually from your computer. You will need to retrieved some secrets from Passbolt and add them to your file `~/.gradle/gradle.properties`: ``` +signing.element.nightly.storeFile=LOCATION_OF_THE_KEYSTORE_FILE signing.element.nightly.storePassword=VALUE_FROM_PASSBOLT signing.element.nightly.keyId=VALUE_FROM_PASSBOLT signing.element.nightly.keyPassword=VALUE_FROM_PASSBOLT diff --git a/enterprise b/enterprise index 6b422f4c8e..5c06c0f1e4 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 6b422f4c8e9e9e5557056e8a861fe3bfd9338f24 +Subproject commit 5c06c0f1e4fa12bf36f73cb8b4ea9018a4dece20 diff --git a/fastlane/metadata/android/en-US/changelogs/202610000.txt b/fastlane/metadata/android/en-US/changelogs/202610000.txt new file mode 100644 index 0000000000..73a90eb460 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202610000.txt @@ -0,0 +1,2 @@ +Main changes in this version: improved sign out screen, updated user status UI, accessibility improvements and bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/features/analytics/api/build.gradle.kts b/features/analytics/api/build.gradle.kts index 5c7f0b223a..f861a375b3 100644 --- a/features/analytics/api/build.gradle.kts +++ b/features/analytics/api/build.gradle.kts @@ -16,6 +16,4 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) - implementation(projects.libraries.androidutils) - implementation(projects.libraries.uiStrings) } diff --git a/features/analytics/api/src/main/res/values-ro/translations.xml b/features/analytics/api/src/main/res/values-ro/translations.xml index 04e0c00a06..77ba3f0e4e 100644 --- a/features/analytics/api/src/main/res/values-ro/translations.xml +++ b/features/analytics/api/src/main/res/values-ro/translations.xml @@ -1,6 +1,6 @@ - "Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme." + "Partajați date anonime de utilizare pentru a ne ajuta să identificăm probleme." "Puteți citi toate condițiile noastre %1$s." "aici" "Partajați datele analitice" diff --git a/features/analytics/impl/src/main/res/values-ro/translations.xml b/features/analytics/impl/src/main/res/values-ro/translations.xml index f9fd53a184..4300fe3f73 100644 --- a/features/analytics/impl/src/main/res/values-ro/translations.xml +++ b/features/analytics/impl/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ "Nu vom înregistra și nu vom face profiluri cu privire la datele personale." - "Distribuiți date anonime de utilizare pentru a ne ajuta să identificăm probleme." + "Partajați date anonime de utilizare pentru a ne ajuta să identificăm probleme." "Puteți citi toate condițiile noastre %1$s." "aici" "Puteți dezactiva această opțiune oricând din setări" diff --git a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt index d743ae4cd6..56a09a1f11 100644 --- a/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt +++ b/features/announcement/api/src/main/kotlin/io/element/android/features/announcement/api/Announcement.kt @@ -17,4 +17,8 @@ sealed interface Announcement { } data object NewNotificationSound : Announcement + + data object MultiAccount : Announcement + + data object SwipeAvatarToSwitchAccount : Announcement } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt index adb81db61a..caf0001ee3 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementService.kt @@ -29,10 +29,12 @@ class DefaultAnnouncementService( ) : AnnouncementService { override suspend fun showAnnouncement(announcement: Announcement) { when (announcement) { - is Announcement.Fullscreen -> showFullscreenAnnouncement(announcement) + is Announcement.Fullscreen -> showAnnouncementIfNeverShown(announcement) Announcement.NewNotificationSound -> { announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) } + Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) + Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement) } } @@ -44,16 +46,27 @@ class DefaultAnnouncementService( return combine( flowOf(Unit), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), - ) { _, newNotificationSoundStatus -> + announcementStore.announcementStatusFlow(Announcement.MultiAccount), + announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount), + ) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus -> buildList { if (newNotificationSoundStatus == AnnouncementStatus.Show) { add(Announcement.NewNotificationSound) } + // The multi-account announcement is pending until it is dismissed. Users who were already + // logged in when the feature became available still have the default NeverShown status. + if (multiAccountStatus != AnnouncementStatus.Shown) { + add(Announcement.MultiAccount) + } + // The swipe avatar announcement is pending until it is dismissed, or the user switches account by swiping. + if (swipeAvatarToSwitchAccountStatus != AnnouncementStatus.Shown) { + add(Announcement.SwipeAvatarToSwitchAccount) + } } } } - private suspend fun showFullscreenAnnouncement(announcement: Announcement.Fullscreen) { + private suspend fun showAnnouncementIfNeverShown(announcement: Announcement) { val currentValue = announcementStore.announcementStatusFlow(announcement).first() if (currentValue == AnnouncementStatus.NeverShown) { announcementStore.setAnnouncementStatus(announcement, AnnouncementStatus.Show) diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt index cb962f8968..b724bfacc7 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/fullscreen/FullscreenAnnouncementView.kt @@ -121,7 +121,6 @@ private fun FullscreenAnnouncementHeader( subTitle = announcement.subtitle(), iconStyle = BigIcon.Style.Default( vectorIcon = announcement.icon(), - usePrimaryTint = true, ), ) } diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt index 2093baa8b3..4e2e11ee2e 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/store/DefaultAnnouncementStore.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.flow.map private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") +private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") +private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement") @ContributesBinding(AppScope::class) class DefaultAnnouncementStore( @@ -36,11 +38,18 @@ class DefaultAnnouncementStore( override fun announcementStatusFlow(announcement: Announcement): Flow { val key = announcement.toKey() // Default-status convention: Shown for one-shot intro/feature announcements that should - // NOT replay on first launch (Fullscreen.Space is disabled; NewNotificationSound is opted - // in by AppMigration08). + // NOT replay on first launch. + // - Fullscreen.Space is disabled + // - NewNotificationSound is opted in by AppMigration08 + // - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters + // the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement. + // - SwipeAvatarToSwitchAccount is NeverShown by default, which means pending. It is set to Shown when the user + // dismisses the banner, or switches account by swiping their avatar. val defaultStatus = when (announcement) { Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown + Announcement.MultiAccount -> AnnouncementStatus.NeverShown + Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown } return store.data.map { prefs -> val ordinal = prefs[key] ?: defaultStatus.ordinal @@ -56,4 +65,6 @@ class DefaultAnnouncementStore( private fun Announcement.toKey() = when (this) { Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.NewNotificationSound -> newNotificationSoundKey + Announcement.MultiAccount -> multiAccountKey + Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt index c72d147c1d..f9df1dbaaf 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/DefaultAnnouncementServiceTest.kt @@ -51,6 +51,38 @@ class DefaultAnnouncementServiceTest { assertThat(announcementStore.announcementStatusFlow(Announcement.NewNotificationSound).first()).isEqualTo(AnnouncementStatus.Show) } + @Test + fun `when showing MultiAccount announcement, announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.MultiAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.MultiAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + + @Test + fun `when showing SwipeAvatarToSwitchAccount announcement, announcement is set to show only if it was never shown`() = runTest { + val announcementStore = InMemoryAnnouncementStore() + val sut = createDefaultAnnouncementService( + announcementStore = announcementStore, + ) + assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.NeverShown) + sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Show) + // Simulate user close the announcement + sut.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) + // Calling again showAnnouncement should not change the value + sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount) + assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Shown) + } + @Test fun `test announcementsToShowFlow`() = runTest { val announcementStore = InMemoryAnnouncementStore() @@ -58,10 +90,18 @@ class DefaultAnnouncementServiceTest { announcementStore = announcementStore, ) sut.announcementsToShowFlow().test { - assertThat(awaitItem()).isEmpty() + // MultiAccount and SwipeAvatarToSwitchAccount are pending by default + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) - assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound) + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) + // Still pending + assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount) + announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) + assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount) + announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown) assertThat(awaitItem()).isEmpty() } } diff --git a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt index ed6dfec850..14a8eea9ab 100644 --- a/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt +++ b/features/announcement/impl/src/test/kotlin/io/element/android/features/announcement/impl/store/InMemoryAnnouncementStore.kt @@ -16,9 +16,13 @@ import kotlinx.coroutines.flow.asStateFlow class InMemoryAnnouncementStore( initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, + initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, ) : AnnouncementStore { private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) + private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) + private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus) override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { announcement.toMutableStateFlow().value = status @@ -31,10 +35,14 @@ class InMemoryAnnouncementStore( override suspend fun reset() { fullScreenAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown + multiAccountAnnouncement.value = AnnouncementStatus.NeverShown + swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown } private fun Announcement.toMutableStateFlow() = when (this) { is Announcement.Fullscreen -> fullScreenAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement + Announcement.MultiAccount -> multiAccountAnnouncement + Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement } } diff --git a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt b/features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt similarity index 96% rename from features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt rename to features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt index fefb61d722..f59ac9bf6c 100644 --- a/features/announcement/test/src/main/kotlin/io/element/android/features/rageshake/test/logs/FakeAnnouncementService.kt +++ b/features/announcement/test/src/main/kotlin/io/element/android/features/announcement/test/FakeAnnouncementService.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.rageshake.test.logs +package io.element.android.features.announcement.test import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/call/api/build.gradle.kts b/features/call/api/build.gradle.kts index 1480a31fd3..572e3fdee4 100644 --- a/features/call/api/build.gradle.kts +++ b/features/call/api/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.kt new file mode 100644 index 0000000000..42190316c3 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/CurrentCallTracker.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.call.api + +/** + * The write side of [CurrentCallService], for whoever is actually running a call. + * + * Separate from [CurrentCallService] because the two have opposite audiences: everything in the app + * that wants to know whether we are in a call reads the service, while only a call implementation + * writes. The WebView path reaches the implementation directly through `ActiveCallManager`, both + * being in `features/call/impl`; the native path is in another module and would otherwise have to + * depend on that impl to report a call it is running itself. + */ +interface CurrentCallTracker { + fun onCallStarted(call: CurrentCall) + + fun onCallEnded() +} diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt new file mode 100644 index 0000000000..aaec286072 --- /dev/null +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/RingingCallTracker.kt @@ -0,0 +1,28 @@ +/* + * 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.call.api + +/** + * Tells the ringing machinery what became of the call it is ringing for. + * + * There is one ring per incoming call, and it is owned by `ActiveCallManager` in + * `features/call/impl` - but *whichever* call implementation answers has to stop it, cancel the + * missed-call timeout and release the wake lock. The WebView path reaches the manager directly, + * being in the same module; the native path is in another one, and this is the narrow slice of it + * that anyone answering a call needs. + */ +interface RingingCallTracker { + /** + * The user is now in the call: stop ringing, take down the incoming-call UI, and cancel the + * timeout that would otherwise post a missed call. + */ + suspend fun onCallJoined(callData: CallData) + + /** The call is over. Clears the active call, and stops a ring that is still in progress. */ + suspend fun onCallEnded(callData: CallData) +} diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index d01e31451e..e80622fa74 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -86,10 +86,10 @@ dependencies { implementation(libs.coil.compose) implementation(libs.serialization.json) implementation(libs.element.call.embedded) + implementation(projects.features.callnative.api) api(projects.features.call.api) testCommonDependencies(libs, true) - testImplementation(projects.features.call.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt index 532db3305d..074ea87551 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt @@ -16,14 +16,24 @@ import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.IntentProvider +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber @ContributesBinding(AppScope::class) class DefaultElementCallEntryPoint( @ApplicationContext private val context: Context, private val activeCallManager: ActiveCallManager, + private val nativeCallEntryPoint: NativeCallEntryPoint, + private val featureFlagService: FeatureFlagService, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : ElementCallEntryPoint { companion object { const val EXTRA_CALL_TYPE = "EXTRA_CALL_TYPE" @@ -31,7 +41,18 @@ class DefaultElementCallEntryPoint( } override fun startCall(callData: CallData) { - context.startActivity(IntentProvider.createIntent(context, callData)) + // The flag is backed by storage, so it has to be read asynchronously. Caching it in an + // eagerly started StateFlow would race: a call placed just after app start could be routed + // on the default value before the real one arrived. + appCoroutineScope.launch { + val useNativeCall = featureFlagService.isFeatureEnabled(FeatureFlags.NativeCall) + Timber.i("startCall: roomId=${callData.roomId}, nativeCall=$useNativeCall") + if (useNativeCall) { + nativeCallEntryPoint.startCall(callData) + } else { + context.startActivity(IntentProvider.createIntent(context, callData)) + } + } } override suspend fun handleIncomingCall( diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt index 647f5ebcfc..82a4ec0bb5 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat import androidx.core.app.Person import dev.zacsweers.metro.Inject import io.element.android.appconfig.ElementCallConfig -import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver import io.element.android.features.call.impl.ui.IncomingCallActivity -import io.element.android.features.call.impl.utils.IntentProvider import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.CommonDrawables @@ -31,7 +29,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import kotlin.time.Duration.Companion.seconds @@ -55,6 +53,15 @@ class RingingCallNotificationCreator( * Request code for the full screen intent. */ const val FULL_SCREEN_INTENT_REQUEST_CODE = 2 + + /** + * Request code for the answer action. + * + * Distinct from [FULL_SCREEN_INTENT_REQUEST_CODE] even though both now target + * `IncomingCallActivity`: they carry different extras, and a shared request code would let + * `FLAG_CANCEL_CURRENT` on one replace the other. + */ + const val ANSWER_REQUEST_CODE = 3 } suspend fun createNotification( @@ -89,14 +96,6 @@ class RingingCallNotificationCreator( .setImportant(true) .build() - val answerIntent = IntentProvider.getPendingIntent( - context, - CallData( - sessionId = sessionId, - roomId = roomId, - isAudioCall = audioOnly, - ), - ) val notificationData = CallNotificationData( sessionId = sessionId, roomId = roomId, @@ -112,6 +111,21 @@ class RingingCallNotificationCreator( audioOnly = audioOnly, ) + // Through IncomingCallActivity rather than straight to ElementCallActivity, so that + // answering goes through ElementCallEntryPoint and lands in whichever call stack the + // NativeCall flag selects. Naming the WebView activity here meant the notification's Answer + // button always opened the WebView, even with native calls turned on. + val answerIntent = PendingIntentCompat.getActivity( + context, + ANSWER_REQUEST_CODE, + Intent(context, IncomingCallActivity::class.java).apply { + putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData) + putExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, true) + }, + PendingIntent.FLAG_CANCEL_CURRENT, + false, + )!! + val declineIntent = PendingIntentCompat.getBroadcast( context, DECLINE_REQUEST_CODE, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index 1d6989fb3c..a4bb9eef31 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -8,6 +8,7 @@ package io.element.android.features.call.impl.ui +import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() { * Extra key for the notification data. */ const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA" + + /** + * Answer straight away rather than showing the incoming-call screen. + * + * Set by the notification's Answer action, which has already asked the user - showing them + * the same question again would be the bug. The screen is for the full-screen intent, where + * the user has not answered anything yet. + */ + const val EXTRA_ANSWER_IMMEDIATELY = "EXTRA_ANSWER_IMMEDIATELY" } @Inject @@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() { ) val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } + if (answerImmediatelyIfAsked(intent)) return if (notificationData != null) { setContent { val colors by remember { @@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() { .launchIn(lifecycleScope) } + /** + * The Answer action can arrive while this activity is already showing the ringing screen, because + * the full screen intent put it there and it is `singleTask`. That delivers a new intent instead + * of recreating the activity, so the answer has to be handled here as well as in [onCreate] - and + * [setIntent] has to run first, or everything reading `intent` afterwards would still see the one + * the activity was created with. + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + answerImmediatelyIfAsked(intent) + } + + /** + * Answers and finishes when the intent is the notification's Answer action, and reports whether + * it did. + * + * Answering goes through this activity rather than straight to a call screen so that it goes + * through `ElementCallEntryPoint`, which is what decides between the WebView and the native + * stack. The notification action used to name `ElementCallActivity` outright, so answering from + * the notification always opened the WebView however the flag was set. + */ + private fun answerImmediatelyIfAsked(intent: Intent?): Boolean { + if (intent == null || !intent.getBooleanExtra(EXTRA_ANSWER_IMMEDIATELY, false)) return false + val notificationData = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) + ?: return false + onAnswer(notificationData) + finish() + return true + } + private fun onAnswer(notificationData: CallNotificationData) { elementCallEntryPoint.startCall( CallData( diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 685fc932fe..74a6b4873b 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -19,9 +19,11 @@ import coil3.annotation.DelicateCoilApi import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.appconfig.ElementCallConfig import io.element.android.features.call.api.CallData import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.RingingCallTracker import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -29,7 +31,7 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler @@ -90,7 +92,8 @@ interface ActiveCallManager { } @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) class DefaultActiveCallManager( @ApplicationContext context: Context, @AppCoroutineScope @@ -103,7 +106,16 @@ class DefaultActiveCallManager( private val appForegroundStateService: AppForegroundStateService, private val imageLoaderHolder: ImageLoaderHolder, private val systemClock: SystemClock, -) : ActiveCallManager { +) : ActiveCallManager, RingingCallTracker { + /** + * The two [RingingCallTracker] entry points are the existing methods under the names a caller + * outside this module can reach. Deliberately not new behaviour: answering has always had to do + * exactly this, and the native path simply could not say so from another module. + */ + override suspend fun onCallJoined(callData: CallData) = joinedCall(callData) + + override suspend fun onCallEnded(callData: CallData) = hangUpCall(callData) + private val tag = "ActiveCallManager" private var timedOutCallJob: Job? = null diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt index 1c8c0b342b..bf29ccd404 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCurrentCallService.kt @@ -11,20 +11,23 @@ package io.element.android.features.call.impl.utils import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding import io.element.android.features.call.api.CurrentCall import io.element.android.features.call.api.CurrentCallService +import io.element.android.features.call.api.CurrentCallTracker import kotlinx.coroutines.flow.MutableStateFlow @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) -class DefaultCurrentCallService : CurrentCallService { +@ContributesBinding(AppScope::class, binding = binding()) +@ContributesBinding(AppScope::class, binding = binding()) +class DefaultCurrentCallService : CurrentCallService, CurrentCallTracker { override val currentCall = MutableStateFlow(CurrentCall.None) - fun onCallStarted(call: CurrentCall) { + override fun onCallStarted(call: CurrentCall) { currentCall.value = call } - fun onCallEnded() { + override fun onCallEnded() { currentCall.value = CurrentCall.None } } diff --git a/features/call/impl/src/main/res/values-ro/translations.xml b/features/call/impl/src/main/res/values-ro/translations.xml index 7d619d5978..fc47f6fd4c 100644 --- a/features/call/impl/src/main/res/values-ro/translations.xml +++ b/features/call/impl/src/main/res/values-ro/translations.xml @@ -1,8 +1,8 @@ "Apel în curs" - "Atingeți pentru a reveni la apel." + "Atingeți pentru a reveni la apel" "☎️ Apel în curs" - "Element Call nu permite utilizarea dispozitivelor audio Bluetooth în această versiune Android. Vă rugăm să selectați un alt dispozitiv audio." + "Element Call nu permite utilizarea dispozitivelor audio Bluetooth în această versiune Android. Selectați un alt dispozitiv audio." "Primiți un apel Element Call" diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt similarity index 64% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt index 9b2ee3d923..76527b1165 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPointTest.kt @@ -6,25 +6,30 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call +package io.element.android.features.call.impl import android.content.Intent import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData -import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.ui.ElementCallActivity -import io.element.android.features.call.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeNativeCallEntryPoint +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test import org.robolectric.RuntimeEnvironment @@ -32,10 +37,13 @@ import org.robolectric.Shadows.shadowOf import kotlin.time.Duration.Companion.seconds class DefaultElementCallEntryPointTest : RobolectricTest() { + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { val entryPoint = createEntryPoint() entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false)) + // The flag is read asynchronously before routing. + advanceUntilIdle() val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java) val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity @@ -43,6 +51,24 @@ class DefaultElementCallEntryPointTest : RobolectricTest() { assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startCall - routes to the native call stack when the feature flag is on`() = runTest { + val startNativeCallLambda = lambdaRecorder {} + val entryPoint = createEntryPoint( + nativeCallEntryPoint = FakeNativeCallEntryPoint(startNativeCallLambda), + isNativeCallEnabled = true, + ) + val callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = true) + + entryPoint.startCall(callData) + advanceUntilIdle() + + startNativeCallLambda.assertions().isCalledOnce().with(value(callData)) + // The WebView Activity must not also be launched. + assertThat(shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity).isNull() + } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { @@ -70,8 +96,15 @@ class DefaultElementCallEntryPointTest : RobolectricTest() { private fun TestScope.createEntryPoint( activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), + nativeCallEntryPoint: NativeCallEntryPoint = FakeNativeCallEntryPoint(), + isNativeCallEnabled: Boolean = false, ) = DefaultElementCallEntryPoint( context = InstrumentationRegistry.getInstrumentation().targetContext, activeCallManager = activeCallManager, + nativeCallEntryPoint = nativeCallEntryPoint, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.NativeCall.key to isNativeCallEnabled), + ), + appCoroutineScope = this, ) } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt index e356358437..da76f088ef 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/MapWebkitPermissionsTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call +package io.element.android.features.call.impl import android.Manifest import android.webkit.PermissionRequest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt similarity index 72% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt index 3f5c1c4e50..7b37c5dcd6 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreatorTest.kt @@ -6,13 +6,13 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.notifications +package io.element.android.features.call.impl.notifications import androidx.core.graphics.drawable.IconCompat import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator +import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -20,12 +20,13 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.coroutines.test.runTest import org.junit.Test +import org.robolectric.Shadows.shadowOf class RingingCallNotificationCreatorTest : RobolectricTest() { @Test @@ -89,6 +90,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() { assertThat(acceptAction?.title?.toString()).isEqualTo("Answer") } + @Test + fun `createNotification - the answer action goes through IncomingCallActivity, asking it to answer straight away`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + val notification = notificationCreator.createTestNotification() + + // Not ElementCallActivity: naming the WebView activity here made the Answer button open the + // WebView call whatever FeatureFlags.NativeCall was set to. + val answerIntent = shadowOf(notification?.actions?.get(1)?.actionIntent).savedIntent + assertThat(answerIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name) + assertThat(answerIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue() + assertThat(answerIntent.hasExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA)).isTrue() + } + + @Test + fun `createNotification - tapping the notification body answers too`() = runTest { + val notificationCreator = createRingingCallNotificationCreator( + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }), + ) + + val notification = notificationCreator.createTestNotification() + + val contentIntent = shadowOf(notification?.contentIntent).savedIntent + assertThat(contentIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name) + assertThat(contentIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue() + } + private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt index f0cdd44082..3193a73343 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallDataTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallDataTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt index f07f7039d3..c75d14a599 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenBackPressPolicyTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenBackPressPolicyTest.kt @@ -5,11 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.ui.CallScreenBackPressAction -import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy import org.junit.Test class CallScreenBackPressPolicyTest { diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt similarity index 96% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt index 276e6670f1..2889869565 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow @@ -14,13 +14,10 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.features.call.api.CallData -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenNavigator -import io.element.android.features.call.impl.ui.CallScreenPresenter +import io.element.android.features.call.impl.utils.FakeActiveCallManager +import io.element.android.features.call.impl.utils.FakeCallWidgetProvider +import io.element.android.features.call.impl.utils.FakeWidgetMessageInterceptor import io.element.android.features.call.impl.utils.WidgetMessageSerializer -import io.element.android.features.call.utils.FakeActiveCallManager -import io.element.android.features.call.utils.FakeCallWidgetProvider -import io.element.android.features.call.utils.FakeWidgetMessageInterceptor import io.element.android.libraries.androidutils.json.DefaultJsonProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.coroutine.CoroutineDispatchers diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt similarity index 93% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt index 76ce8e8ede..4107a0826a 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenViewTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import android.view.KeyEvent import android.webkit.WebView @@ -18,11 +18,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.call.impl.pip.PictureInPictureEvent import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState -import io.element.android.features.call.impl.ui.CallScreenEvent -import io.element.android.features.call.impl.ui.CallScreenState -import io.element.android.features.call.impl.ui.CallScreenView -import io.element.android.features.call.impl.ui.JavascriptBackHandlerBridge -import io.element.android.features.call.impl.ui.aCallScreenState import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.robolectric.RobolectricTest diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt similarity index 78% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt index 431b746362..f4a0b7e16a 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeCallScreenNavigator.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui - -import io.element.android.features.call.impl.ui.CallScreenNavigator +package io.element.android.features.call.impl.ui class FakeCallScreenNavigator : CallScreenNavigator { var closeCalled = false diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt similarity index 79% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt index f68842813b..4329174636 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/FakeLanguageTagProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/FakeLanguageTagProvider.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.ui +package io.element.android.features.call.impl.ui import androidx.compose.runtime.Composable -import io.element.android.features.call.impl.ui.LanguageTagProvider class FakeLanguageTagProvider(private val languageTag: String?) : LanguageTagProvider { @Composable diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt similarity index 98% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt index c95f8c5088..6820985825 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultActiveCallManagerTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import android.os.PowerManager import androidx.core.app.NotificationManagerCompat @@ -16,10 +16,6 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.aCallNotificationData -import io.element.android.features.call.impl.utils.ActiveCall -import io.element.android.features.call.impl.utils.CallState -import io.element.android.features.call.impl.utils.DefaultActiveCallManager -import io.element.android.features.call.impl.utils.DefaultCurrentCallService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId @@ -34,7 +30,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider 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 -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.test.notifications.FakeOnMissedCallNotificationHandler diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt similarity index 98% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt index 093abfcd32..69d95ac12c 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProviderTest.kt @@ -6,10 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.impl.utils.DefaultCallWidgetProvider import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.test.A_ROOM_ID diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt similarity index 88% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt index c2c38284a9..96fb0cb5cb 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeActiveCallManager.kt @@ -6,12 +6,10 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils import io.element.android.features.call.api.CallData import io.element.android.features.call.impl.notifications.CallNotificationData -import io.element.android.features.call.impl.utils.ActiveCall -import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt similarity index 90% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt index ebc00b0e8f..98789c1d71 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeCallWidgetProvider.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils -import io.element.android.features.call.impl.utils.CallWidgetProvider import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.kt new file mode 100644 index 0000000000..dcabb72507 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeNativeCallEntryPoint.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.call.impl.utils + +import io.element.android.features.call.api.CallData +import io.element.android.features.callnative.api.NativeCallEntryPoint + +class FakeNativeCallEntryPoint( + private val startCallLambda: (CallData) -> Unit = {}, +) : NativeCallEntryPoint { + override fun startCall(callData: CallData) = startCallLambda(callData) +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt similarity index 84% rename from features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt rename to features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt index 6b68b1519c..8fff92faf3 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/utils/FakeWidgetMessageInterceptor.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.call.utils +package io.element.android.features.call.impl.utils -import io.element.android.features.call.impl.utils.WidgetMessageInterceptor import kotlinx.coroutines.flow.MutableSharedFlow class FakeWidgetMessageInterceptor : WidgetMessageInterceptor { diff --git a/features/call/test/build.gradle.kts b/features/call/test/build.gradle.kts index f06c5ed16e..920670287e 100644 --- a/features/call/test/build.gradle.kts +++ b/features/call/test/build.gradle.kts @@ -17,7 +17,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) api(projects.features.call.api) implementation(projects.libraries.matrix.api) diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt new file mode 100644 index 0000000000..b6e31f8882 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeCurrentCallTracker.kt @@ -0,0 +1,24 @@ +/* + * 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.call.test + +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallTracker + +class FakeCurrentCallTracker : CurrentCallTracker { + /** Every transition in order, so a test can assert the call was reported before it was cleared. */ + val calls = mutableListOf() + + override fun onCallStarted(call: CurrentCall) { + calls += call + } + + override fun onCallEnded() { + calls += CurrentCall.None + } +} diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt new file mode 100644 index 0000000000..915641a0a4 --- /dev/null +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeRingingCallTracker.kt @@ -0,0 +1,24 @@ +/* + * 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.call.test + +import io.element.android.features.call.api.CallData +import io.element.android.features.call.api.RingingCallTracker + +class FakeRingingCallTracker : RingingCallTracker { + val joined = mutableListOf() + val ended = mutableListOf() + + override suspend fun onCallJoined(callData: CallData) { + joined += callData + } + + override suspend fun onCallEnded(callData: CallData) { + ended += callData + } +} diff --git a/features/callnative/api/build.gradle.kts b/features/callnative/api/build.gradle.kts new file mode 100644 index 0000000000..f5f3c25f4b --- /dev/null +++ b/features/callnative/api/build.gradle.kts @@ -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. + */ + +plugins { + // NativeCallHost renders the call, so this module carries a composable. + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.callnative.api" +} + +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android. Goes with the repositories in settings.gradle.kts. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + +dependencies { + // api, not implementation: ElementCallMatrixTransport is part of ElementCallTransportFactory's + // signature, and CallData of NativeCallEntryPoint's. + // api, so that consumers of this module inherit the versions the BOM pins - the artifact + // below is declared without one. + api(platform("io.element.android:element-call-bom:$elementCallVersion")) + api(libs.element.call.api) + api(projects.features.call.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt new file mode 100644 index 0000000000..bda31738f0 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/ElementCallTransportFactory.kt @@ -0,0 +1,26 @@ +/* + * 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.callnative.api + +import io.element.android.call.api.matrix.ElementCallMatrixTransport +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * Makes the native call's view of Matrix for a session. + * + * The component's turnkey transport is built over a raw Rust SDK client, and only + * `libraries/matrix/impl` is compiled against the SDK. This is the seam that keeps it that way: the + * implementation lives there, everything else takes a [MatrixClient] and asks. + * + * A factory rather than a binding for the transport itself, because the call is started from app scope + * - a call can be placed for an account that is not the one on screen - and the session it belongs to + * is known only as an id until a client is fetched for it. + */ +fun interface ElementCallTransportFactory { + fun create(client: MatrixClient): ElementCallMatrixTransport +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt new file mode 100644 index 0000000000..486a0b14c1 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt @@ -0,0 +1,24 @@ +/* + * 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.callnative.api + +import io.element.android.features.call.api.CallData + +/** + * Starts a call on the native MatrixRTC stack instead of the Element Call WebView. + * + * Experimental: gated behind `FeatureFlags.NativeCall` and reached only through + * [io.element.android.features.call.api.ElementCallEntryPoint], so nothing else in the app has to know + * it exists. + * + * There is nothing behind this seam yet - the default implementation does nothing and says so. The + * native stack that implements it is a separate library, added in a follow-up. + */ +fun interface NativeCallEntryPoint { + fun startCall(callData: CallData) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt new file mode 100644 index 0000000000..c90ffe1e29 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt @@ -0,0 +1,26 @@ +/* + * 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.callnative.api + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Where a native call is drawn, wrapped around the logged-in content. + * + * A wrapper rather than an overlay, because the two states of a call need different things from the + * layout: minimized, the call takes a strip of height above the content; maximized, it covers it. The + * [Modifier] handed back to [content] has consumed whatever window insets the call has already taken, + * so the screens underneath do not pad for the status bar a second time. + * + * With no call running this renders [content] and nothing else. + */ +interface NativeCallHost { + @Composable + fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt new file mode 100644 index 0000000000..0cd8ea9110 --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt @@ -0,0 +1,26 @@ +/* + * 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.callnative.api + +import android.app.Activity + +/** + * The one part of picture-in-picture that an Activity has to do itself. + * + * Everything else is installed where the call is drawn, which is inside the logged-in content and so + * knows which session's call it is. This does not: `onUserLeaveHint` is an Activity override, there is + * no session in scope at that point, and it applies to whichever call happens to be running. + * + * Only Android 11 and below reach this. From Android 12 the system is told up front that the Activity + * would like to shrink, which covers every way out of the app including the home gesture - the one + * people actually use, and the one this hook never sees. + */ +interface NativeCallPip { + /** Call from the host Activity's `onUserLeaveHint()` override. */ + fun onUserLeaveHint(activity: Activity) +} diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts new file mode 100644 index 0000000000..a7fdd18252 --- /dev/null +++ b/features/callnative/impl/build.gradle.kts @@ -0,0 +1,52 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * 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. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.callnative.impl" +} + +setupDependencyInjection() + +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal +// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + +dependencies { + api(projects.features.callnative.api) + + implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) + implementation(libs.element.call.api) + implementation(libs.element.call.impl) + implementation(libs.element.call.ui) + + implementation(projects.features.call.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.architecture) + implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.preferences.api) + // For NotificationIdProvider: the ongoing-call notification id is shared with the WebView call's + // foreground service, so that the two paths cannot leave two call notifications in the shade. + implementation(projects.libraries.push.api) + implementation(projects.services.appnavstate.api) + implementation(libs.androidx.corektx) + + testCommonDependencies(libs) + testImplementation(libs.element.call.test) + testImplementation(projects.features.call.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt new file mode 100644 index 0000000000..6b40e72dbf --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.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. + */ + +package io.element.android.features.callnative.impl + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.rtc.id.RoomId +import io.element.android.features.call.api.CallData +import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Starts a call on the native MatrixRTC stack. + * + * App-scoped, while the stack it drives is per session: the session comes from the call rather than + * from whatever is on screen, so answering a notification for a second account reaches that account's + * stack. + */ +@ContributesBinding(AppScope::class) +class DefaultNativeCallEntryPoint( + @ApplicationContext private val context: Context, + private val controllers: ElementCallControllers, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : NativeCallEntryPoint { + override fun startCall(callData: CallData) { + appCoroutineScope.launch { + val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch + controller.startCall( + ElementCallData( + roomId = RoomId(callData.roomId.value), + isAudioCall = callData.isAudioCall, + // Temporary, to be removed from the library soon: the core already skips the ring when anyone else is in the call. + isStartingCall = true, + ) + ) + // The call screen asks for the microphone itself, and is where the answer normally comes + // from. This covers the case it cannot reach: a call answered from a notification while + // the app is in the background has no call screen composed yet, and a call waiting on a + // permission nobody is there to grant would sit unanswered. Reporting a permission we + // already hold is idempotent, so the screen asking again later costs nothing. + if (hasMicrophonePermission()) { + controller.setMicrophonePermissionGranted(true) + } + } + } + + private fun hasMicrophonePermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt new file mode 100644 index 0000000000..332f28f7d8 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt @@ -0,0 +1,68 @@ +/* + * 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.callnative.impl + +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.impl.ElementCallPictureInPicture +import io.element.android.call.ui.ElementCallOverlay +import io.element.android.features.callnative.api.NativeCallHost +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import timber.log.Timber + +/** + * Draws the session's native call around the logged-in content. + * + * Session-scoped, unlike the entry point: this renders inside the logged-in navigation, so the + * session is already in scope and there is no need to go looking for one. The stack it draws is not, + * so this asks the app-scoped registry for its own session's controller. + * + * That controller only exists once a call has been placed or answered. Until then the overlay draws + * the content alone. + */ +@ContributesBinding(SessionScope::class) +class DefaultNativeCallHost( + private val client: MatrixClient, + private val controllers: ElementCallControllers, +) : NativeCallHost { + @Composable + override fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) { + val controller by remember(client.sessionId) { controllers.controller(client.sessionId) } + .collectAsState(initial = null) + val current = controller + + // Installed here rather than in the Activity because this is the only place that has both the + // Activity and the session whose call it is. The Activity keeps `onUserLeaveHint`, which it + // cannot delegate: see NativeCallPip. + val activity = LocalActivity.current as? ComponentActivity + DisposableEffect(activity, current) { + if (activity != null && current != null) { + runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) } + .onFailure { Timber.w(it, "NativeCall: cannot attach picture-in-picture") } + } + onDispose { } + } + + // Composed even with no controller yet, so the content never moves in the tree and keeps its state. + ElementCallOverlay( + controller = current, + modifier = modifier, + style = rememberElementXCallStyle(), + content = content, + ) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt new file mode 100644 index 0000000000..b94785a931 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt @@ -0,0 +1,31 @@ +/* + * 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.callnative.impl + +import android.app.Activity +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.impl.ElementCallPictureInPicture +import io.element.android.features.callnative.api.NativeCallPip + +/** + * Forwards the Activity's leave hint to whichever session has a call. + * + * App-scoped because `onUserLeaveHint` is, and it asks for the running call rather than a particular + * session's: only one call runs at a time, and the Activity has no session in scope to name. Sessions + * whose stack was never built are skipped, so asking cannot start one. + */ +@ContributesBinding(AppScope::class) +class DefaultNativeCallPip( + private val controllers: ElementCallControllers, +) : NativeCallPip { + override fun onUserLeaveHint(activity: Activity) { + val controller = controllers.withRunningCall() ?: return + ElementCallPictureInPicture.onUserLeaveHint(activity, controller) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt new file mode 100644 index 0000000000..5792da9a93 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt @@ -0,0 +1,180 @@ +/* + * 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.callnative.impl + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.call.api.ElementCallController +import io.element.android.call.api.ElementCallNotificationConfig +import io.element.android.call.api.ElementCallOptions +import io.element.android.call.api.rtc.MatrixRtcLogLevel +import io.element.android.call.api.rtc.MatrixRtcLoggingConfiguration +import io.element.android.call.impl.ElementCallStack +import io.element.android.features.call.api.CurrentCallTracker +import io.element.android.features.call.api.RingingCallTracker +import io.element.android.features.callnative.api.ElementCallTransportFactory +import io.element.android.libraries.di.annotations.ApplicationContext +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.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.tracing.LogLevel +import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import io.element.android.libraries.push.api.notifications.ForegroundServiceType +import io.element.android.libraries.push.api.notifications.NotificationIdProvider +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.job +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +/** + * The native call controller of a session, built on demand. + * + * An interface so that what starts calls can be tested without the real stack, which loads the RTC + * native library. + */ +interface ElementCallControllers { + /** + * The session's controller, building its call stack on first use. Null when there is no such + * session - logged out, or a session id that cannot be restored. + */ + suspend fun getOrBuild(sessionId: SessionId): ElementCallController? + + /** + * The session's controller once its stack exists, and null until then. Never builds one. + * + * A flow rather than a getter because the stack appears part-way through the session's life, when + * a call is first placed or answered, and whatever draws the call has to notice when it does. + */ + fun controller(sessionId: SessionId): Flow + + /** + * The controller of whichever session currently has a call, or null when none does. + * + * For the Activity-level seams, which are app-scoped and have no session of their own to ask. Only + * sessions whose stack is already built are considered, so asking cannot start one. + */ + fun withRunningCall(): ElementCallController? +} + +/** + * Builds one call stack per session, from a Matrix client, and keeps it for the session's life. + * + * App-scoped and keyed by session id rather than session-scoped, because everything that starts a call + * is app-scoped: the push path that resolves a ringing call, the notification it posts, and the Activity + * that answers it all run on a [MatrixClient] fetched by id, and none of them has a session graph. A + * session-scoped stack could only be reached after the user had navigated into the session, so answering + * a call notification after a cold start would find nothing to answer with. + * + * One stack serves every call in a session; each call opens and closes its own room underneath, which + * the component does itself. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultElementCallControllers( + @ApplicationContext private val context: Context, + private val matrixClientProvider: MatrixClientProvider, + private val transportFactory: ElementCallTransportFactory, + private val currentCallTracker: CurrentCallTracker, + private val ringingCallTracker: RingingCallTracker, + private val appForegroundStateService: AppForegroundStateService, + private val appPreferencesStore: AppPreferencesStore, + private val featureFlagService: FeatureFlagService, +) : ElementCallControllers { + // A flow rather than a plain map because what draws the call has to see the stack appear. + private val stacks = MutableStateFlow>(emptyMap()) + + // One at a time across every session: two calls placed at once must not race into two stacks for + // the same session, because only one of them would ever be closed. + private val mutex = Mutex() + + override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? = mutex.withLock { + stacks.value[sessionId]?.let { return@withLock it.controller } + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() + if (client == null) { + Timber.w("NativeCall: no client for $sessionId, cannot start a call") + return@withLock null + } + build(client).also { stack -> + stacks.update { it + (sessionId to stack) } + // The client's scope is the stack's lifetime: the component has no shutdown of its own, so + // cancelling this at logout is what stops the RTC core. Closing here only releases the + // component's own reference, so a dead stack is not left behind it. + client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion { + stacks.update { if (it[sessionId] === stack) it - sessionId else it } + stack.close() + } + }.controller + } + + override fun controller(sessionId: SessionId): Flow = + stacks.map { it[sessionId]?.controller }.distinctUntilChanged() + + override fun withRunningCall(): ElementCallController? = + stacks.value.values.firstNotNullOfOrNull { stack -> + stack.controller.takeIf { it.state.value != null } + } + + private suspend fun build(client: MatrixClient): ElementCallStack = + ElementCallStack.Builder(context, transportFactory.create(client)) + .lifecycleListener( + ElementXCallLifecycleListener( + sessionId = client.sessionId, + currentCallTracker = currentCallTracker, + ringingCallTracker = ringingCallTracker, + sessionCoroutineScope = client.sessionCoroutineScope, + appForegroundStateService = appForegroundStateService, + ) + ) + .roomContext(ElementXRoomContextProvider(client)) + .options(options()) + .build(scope = client.sessionCoroutineScope) + + private suspend fun options() = ElementCallOptions( + logging = loggingConfiguration(), + notification = ElementCallNotificationConfig( + // The same id the WebView call's foreground service uses, so that the two paths cannot + // leave two ongoing-call notifications in the shade. + notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL), + ), + ) + + /** + * Read here rather than in `PlatformInitializer`, where the Rust SDK's tracing is configured: the + * core applies this when it first loads, which is when a call is first placed, and this is the first + * place where both the preference and the flag are readable. + */ + private suspend fun loggingConfiguration(): MatrixRtcLoggingConfiguration { + val logLevel = appPreferencesStore.getTracingLogLevelFlow().first() + return MatrixRtcLoggingConfiguration( + // Floored at debug: turning the SDK level down to quiet the timeline and event-cache + // traces is exactly what you do while working on calls, and it must not also silence the + // RTC core. The core's own filter holds down the parts that log per frame. + logLevel = maxOf(logLevel, LogLevel.DEBUG).toMatrixRtcLogLevel(), + writesToLogcat = featureFlagService.isFeatureEnabled(FeatureFlags.PrintLogsToLogcat), + ) + } + + private fun LogLevel.toMatrixRtcLogLevel(): MatrixRtcLogLevel = when (this) { + LogLevel.ERROR -> MatrixRtcLogLevel.ERROR + LogLevel.WARN -> MatrixRtcLogLevel.WARN + LogLevel.INFO -> MatrixRtcLogLevel.INFO + LogLevel.DEBUG -> MatrixRtcLogLevel.DEBUG + LogLevel.TRACE -> MatrixRtcLogLevel.TRACE + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt new file mode 100644 index 0000000000..b64617639d --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListener.kt @@ -0,0 +1,65 @@ +/* + * 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.callnative.impl + +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.ElementCallEndReason +import io.element.android.call.api.ElementCallLifecycleListener +import io.element.android.features.call.api.CallData +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.api.CurrentCallTracker +import io.element.android.features.call.api.RingingCallTracker +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.services.appnavstate.api.AppForegroundStateService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Reports a native call back to the parts of Element X that already track calls. + * + * The library knows it is in a call; Element X's room list, timeline and ringing machinery each + * already know how to react to one. This joins the two, so that a native call looks like any other + * call to the rest of the app rather than needing every reader to learn about it. + */ +class ElementXCallLifecycleListener( + private val sessionId: SessionId, + private val currentCallTracker: CurrentCallTracker, + private val ringingCallTracker: RingingCallTracker, + private val sessionCoroutineScope: CoroutineScope, + appForegroundStateService: AppForegroundStateService, +) : ElementCallLifecycleListener { + override val isAppInForeground: Flow = appForegroundStateService.isInForeground + + override fun onCallStarted(callData: ElementCallData) { + currentCallTracker.onCallStarted(CurrentCall.RoomCall(callData.roomId())) + } + + override fun onCallJoined(callData: ElementCallData) { + // The ringing side is suspending and these callbacks are not, so the work is handed to the + // session scope rather than blocking the controller's. It outlives the call on purpose: a + // ring still has to be taken down even if the call ends while this is in flight. + sessionCoroutineScope.launch { ringingCallTracker.onCallJoined(callData.toCallData()) } + } + + override fun onCallEnded(callData: ElementCallData?, reason: ElementCallEndReason) { + Timber.i("Native call ended: $reason") + currentCallTracker.onCallEnded() + callData?.let { sessionCoroutineScope.launch { ringingCallTracker.onCallEnded(it.toCallData()) } } + } + + private fun ElementCallData.roomId() = RoomId(roomId.value) + + private fun ElementCallData.toCallData() = CallData( + sessionId = sessionId, + roomId = roomId(), + isAudioCall = isAudioCall, + ) +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt new file mode 100644 index 0000000000..751679b298 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt @@ -0,0 +1,57 @@ +/* + * 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.callnative.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.element.android.call.ui.theme.ElementCallColors +import io.element.android.call.ui.theme.ElementCallIcons +import io.element.android.call.ui.theme.ElementCallStyle +import io.element.android.call.ui.theme.ElementCallTypography +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType + +/** + * How Element X dresses the native call. + * + * Deliberately almost nothing. The component's own defaults are already Compound - its colours are + * Compound's dark palette and its glyphs are Compound vectors - so re-deriving them from + * `ElementTheme` would buy no visual change and a lot of mapping to keep in step. The call also stays + * dark in a light-themed app on purpose: it is a video surface, as it is in Element Call on the web. + * + * What the component genuinely cannot do is turn an `mxc://` URL into a picture, because that needs a + * Matrix media loader. So the avatar is the one thing overridden here, and it is what makes faces + * appear on the tiles rather than initials. + */ +@Composable +fun rememberElementXCallStyle(): ElementCallStyle { + val icons = ElementCallIcons.default() + return remember(icons) { + ElementCallStyle( + colors = ElementCallColors.Dark, + typography = ElementCallTypography.Default, + icons = icons, + avatar = { data, size -> + Avatar( + avatarData = AvatarData( + id = data.id, + name = data.name, + url = data.url, + // Unused: forcedAvatarSize below is what decides the size, and the component + // asks in dp because it lays the tiles out itself. + size = AvatarSize.ActiveCallItem, + ), + avatarType = AvatarType.User, + forcedAvatarSize = size, + ) + }, + ) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.kt new file mode 100644 index 0000000000..aed2f97725 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXRoomContextProvider.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.callnative.impl + +import io.element.android.call.api.ElementCallRoomContext +import io.element.android.call.api.ElementCallRoomContextProvider +import io.element.android.call.api.ElementCallRoomMember +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.roomMembers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import timber.log.Timber +import io.element.android.call.api.rtc.id.RoomId as ElementCallRoomId +import io.element.android.call.api.rtc.id.UserId as ElementCallUserId +import io.element.android.libraries.matrix.api.core.RoomId as MatrixRoomId + +/** + * Tells the call what the room is called and who is in it. + * + * The RTC layer knows a participant only as a member id and a user id - it has no reason to know + * what anyone is called or what they look like. Element X does, so the join happens here rather than + * in the call UI, and it goes through Element X's room cache so that names and avatars are the same + * ones the rest of the app shows. The library ships a provider of its own that reads the SDK + * directly; it is not used, because it would miss exactly that cache. + */ +class ElementXRoomContextProvider( + private val matrixClient: MatrixClient, +) : ElementCallRoomContextProvider { + override fun roomContext(roomId: ElementCallRoomId): Flow = flow { + val room = matrixClient.getRoom(MatrixRoomId(roomId.value)) + if (room == null) { + // Emits nothing rather than guessing: the call then shows user ids, which is better than + // a name we made up. + Timber.w("NativeCall: no room for ${roomId.value}, the call will show user ids") + return@flow + } + room.use { + coroutineScope { + // The member list is loaded lazily, and a call is exactly the moment it is needed: + // without this the tiles would show user ids until something else happened to ask. + // Launched rather than awaited so the room name still arrives immediately. + launch { room.updateMembers() } + emitAll( + combine(room.roomInfoFlow, room.membersStateFlow) { info, membersState -> + ElementCallRoomContext( + displayName = info.name, + isDm = info.isDm, + members = membersState.toElementCallMembers(), + ) + } + ) + } + } + } + + private fun RoomMembersState.toElementCallMembers(): Map = + roomMembers().orEmpty().associate { member -> + ElementCallUserId(member.userId.value) to ElementCallRoomMember( + userId = ElementCallUserId(member.userId.value), + displayName = member.displayName, + avatarUrl = member.avatarUrl, + ) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt new file mode 100644 index 0000000000..cd209069e7 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt @@ -0,0 +1,103 @@ +/* + * 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.callnative.impl + +import android.Manifest +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.call.test.FakeElementCallController +import io.element.android.features.call.api.CallData +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNativeCallEntryPointTest : RobolectricTest() { + @Test + fun `starting a call answers the microphone permission the app already holds`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value) + // The call screen normally answers this, but it is not composed when a call is answered from + // a notification with the app in the background - and a call waiting on a permission nobody + // is there to grant sits unanswered and refuses every later attempt. + assertThat(controller.microphonePermissionAnswers).containsExactly(true) + } + + @Test + fun `a call the user placed opens on the call screen, not in the minimized bar`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + // A snapshot starts maximized, which is what the user asked for by pressing call. Saying + // otherwise here is what a UI-less build had to do, and it would now dock a freshly placed + // call into the bar. + assertThat(controller.maximizedCalls).isEmpty() + } + + @Test + fun `the call still starts without the microphone permission, so the call screen can ask`() = runTest { + shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO) + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls).hasSize(1) + // Nothing to report, and nothing to report it from: only an Activity can ask, and the call + // screen does exactly that once it appears. + assertThat(controller.microphonePermissionAnswers).isEmpty() + } + + @Test + fun `nothing happens for a session that cannot be restored`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller, sessionCanBeRestored = false) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls).isEmpty() + } + + private fun grantMicrophonePermission() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO) + } + + private fun TestScope.createEntryPoint( + controller: FakeElementCallController, + sessionCanBeRestored: Boolean = true, + ) = DefaultNativeCallEntryPoint( + context = InstrumentationRegistry.getInstrumentation().targetContext as Application, + controllers = FakeElementCallControllers(controller.takeIf { sessionCanBeRestored }), + appCoroutineScope = this, + ) + + private companion object { + val A_CALL_DATA = CallData(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, isAudioCall = true) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt new file mode 100644 index 0000000000..1e7cc0df92 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/ElementXCallLifecycleListenerTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.ElementCallEndReason +import io.element.android.call.api.rtc.id.RoomId +import io.element.android.features.call.api.CurrentCall +import io.element.android.features.call.test.FakeCurrentCallTracker +import io.element.android.features.call.test.FakeRingingCallTracker +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.services.appnavstate.test.FakeAppForegroundStateService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ElementXCallLifecycleListenerTest { + @Test + fun `a started call is reported as the current call, and cleared when it ends`() = runTest { + val currentCallTracker = FakeCurrentCallTracker() + val listener = createListener(currentCallTracker = currentCallTracker) + + listener.onCallStarted(A_CALL) + listener.onCallEnded(A_CALL, ElementCallEndReason.LEFT) + + assertThat(currentCallTracker.calls).containsExactly( + CurrentCall.RoomCall(A_ROOM_ID), + CurrentCall.None, + ).inOrder() + } + + @Test + fun `joining a call stops the ring`() = runTest { + val ringingCallTracker = FakeRingingCallTracker() + val listener = createListener(ringingCallTracker = ringingCallTracker) + + listener.onCallJoined(A_CALL) + runCurrent() + + assertThat(ringingCallTracker.joined.map { it.roomId }).containsExactly(A_ROOM_ID) + assertThat(ringingCallTracker.ended).isEmpty() + } + + @Test + fun `ending a call tells the ringing side, and survives a call that never had data`() = runTest { + val ringingCallTracker = FakeRingingCallTracker() + val currentCallTracker = FakeCurrentCallTracker() + val listener = createListener( + currentCallTracker = currentCallTracker, + ringingCallTracker = ringingCallTracker, + ) + + listener.onCallEnded(null, ElementCallEndReason.LEFT) + runCurrent() + + // The current call is always cleared; only the ring needs to know which call it was. + assertThat(currentCallTracker.calls).containsExactly(CurrentCall.None) + assertThat(ringingCallTracker.ended).isEmpty() + } + + private fun TestScope.createListener( + currentCallTracker: FakeCurrentCallTracker = FakeCurrentCallTracker(), + ringingCallTracker: FakeRingingCallTracker = FakeRingingCallTracker(), + ) = ElementXCallLifecycleListener( + sessionId = A_SESSION_ID, + currentCallTracker = currentCallTracker, + ringingCallTracker = ringingCallTracker, + sessionCoroutineScope = this, + appForegroundStateService = FakeAppForegroundStateService(), + ) + + private companion object { + val A_CALL = ElementCallData(roomId = RoomId(A_ROOM_ID.value), isAudioCall = true, isStartingCall = true) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt new file mode 100644 index 0000000000..fb87f494d5 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import io.element.android.call.api.ElementCallController +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be + * restored - logged out, or an id from a stale notification. + */ +class FakeElementCallControllers( + private val controller: ElementCallController? = null, +) : ElementCallControllers { + val requestedSessions = mutableListOf() + + override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? { + requestedSessions += sessionId + return controller + } + + override fun controller(sessionId: SessionId): Flow = flowOf(controller) + + override fun withRunningCall(): ElementCallController? = + controller?.takeIf { it.state.value != null } +} diff --git a/features/contentscanner/api/build.gradle.kts b/features/contentscanner/api/build.gradle.kts index 0e3d592878..b90b0c277f 100644 --- a/features/contentscanner/api/build.gradle.kts +++ b/features/contentscanner/api/build.gradle.kts @@ -8,16 +8,8 @@ android { } dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) - testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) - testImplementation(libs.molecule.runtime) - testImplementation(libs.test.truth) - testImplementation(libs.test.turbine) - testImplementation(projects.libraries.matrix.test) } diff --git a/features/contentscanner/impl/build.gradle.kts b/features/contentscanner/impl/build.gradle.kts index 34558a182e..1f17a0d991 100644 --- a/features/contentscanner/impl/build.gradle.kts +++ b/features/contentscanner/impl/build.gradle.kts @@ -24,7 +24,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 2cd3961ead..ce0584828b 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.deeplink.api) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) @@ -52,7 +51,6 @@ dependencies { testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.features.startchat.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt index b23226b9c0..f95af65fd8 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -11,7 +11,6 @@ package io.element.android.features.createroom.impl.addpeople import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -68,7 +67,6 @@ fun AddPeopleView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AddPeopleTopBar( onSkipClick: () -> Unit, diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 849647a9d3..86cd67546e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -92,6 +93,9 @@ class ConfigureRoomPresenter( val isKnockFeatureEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(initial = false) + val arePublicRoomsDisabled by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + } val roomAddressValidity = remember { mutableStateOf(RoomAddressValidity.Unknown) } @@ -145,12 +149,13 @@ class ConfigureRoomPresenter( // 1. If we are creating a space. // 2. If it has a parent space. // 3. If knocking is enabled. + // 4. If the homeserver forbids public rooms. val parentSpace = createRoomConfig.parentSpace - val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) { + val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) { when { isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull( - JoinRuleItem.PublicVisibility.Public, + JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.Private, ).toImmutableList() 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 fec820ffbb..053149ccfe 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 @@ -24,7 +24,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -179,7 +178,6 @@ fun ConfigureRoomView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConfigureRoomToolbar( isSpace: Boolean, diff --git a/features/createroom/impl/src/main/res/values-cs/translations.xml b/features/createroom/impl/src/main/res/values-cs/translations.xml index 10b29b7b17..bfa84cb116 100644 --- a/features/createroom/impl/src/main/res/values-cs/translations.xml +++ b/features/createroom/impl/src/main/res/values-cs/translations.xml @@ -11,7 +11,7 @@ "Soukromé" "Tuto místnost může najít kdokoli. To můžete kdykoli změnit v nastavení místnosti." - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Veřejné" "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout." "Povolit žádost o vstup" @@ -19,7 +19,7 @@ To můžete kdykoli změnit v nastavení místnosti." "Požádat o vstup" "Vstoupit mohou pouze pozvaní lidé." "Soukromé" - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Veřejné" "Kdokoli může vstoupit do %1$s." "Standard" diff --git a/features/createroom/impl/src/main/res/values-el/translations.xml b/features/createroom/impl/src/main/res/values-el/translations.xml index 1e6d7841ec..8e27520ccc 100644 --- a/features/createroom/impl/src/main/res/values-el/translations.xml +++ b/features/createroom/impl/src/main/res/values-el/translations.xml @@ -11,7 +11,7 @@ "Ιδιωτικό" "Ο καθένας μπορεί να βρει αυτή την αίθουσα. Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας." - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας." "Δημόσιο" "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα." "Επιτρέψτε την αίτηση συμμετοχής" @@ -19,7 +19,7 @@ "Αίτημα συμμετοχής" "Μόνο άτομα που έχουν προσκληθεί μπορούν να συμμετάσχουν." "Ιδιωτικό" - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων και ατόμων εκτός του οργανισμού σας." "Δημόσιο" "Οποιοσδήποτε στο %1$s μπορεί να συμμετάσχει." "Πρότυπο" diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml index 33a5351877..70d311af8f 100644 --- a/features/createroom/impl/src/main/res/values-ro/translations.xml +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ "Cameră nouă" - "Invitați prieteni" + "Invitați persoane" "A apărut o eroare la crearea camerei" "Spațiul nu a putut fi creat din cauza unei erori necunoscute. Încercați din nou mai târziu." "Adăugați un nume…" @@ -10,24 +10,24 @@ "Doar persoanele invitate se pot alătura." "Privat" "Oricine poate găsi această cameră. -Puteți modifica acest lucru oricând în setări." - "Oricine se poate alătura." +Puteți modifica acest lucru oricând în setările camerei." + "Oricine se poate alătura, inclusiv persoane din afara organizației." "Public" - "Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea" - "Permite solicitarea de alăturare" + "Oricine poate solicita să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea." + "Permiteți solicitările de alăturare" "Oricine din %1$s se poate alătura, dar oricine altcineva trebuie să solicite acces." "Solicitați să vă alăturați" "Doar persoanele invitate se pot alătura." "Privat" - "Oricine se poate alătura acestei camere" + "Oricine se poate alătura acestei camere, inclusiv persoane din afara organizației." "Public" "Oricine din %1$s se poate alătura." "Standard" "Cine are acces" - "Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră." + "Pentru ca această cameră să fie vizibilă în catalogul public, veți avea nevoie de o adresă de cameră." "Adresă" "Vizibilitatea camerei" - "(nicun spațiu)" + "(niciun spațiu)" "Nu adăugați la un spațiu" "Niciun spațiu selectat" "Adăugați la spațiu" diff --git a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml index 5d00e24603..54301a4d44 100644 --- a/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/createroom/impl/src/main/res/values-zh-rTW/translations.xml @@ -11,7 +11,7 @@ "私人" "任何人都可以找到此聊天室。 您隨時都可以在聊天室設定中變更此設定。" - "任何人都可以加入。" + "任何人都可以加入,包括貴組織以外的人士。" "公開" "任何人都可以要求加入,但管理員或版主必須接受該請求" "允許要求加入" diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml index b9934dfb63..8b0f251bdb 100644 --- a/features/createroom/impl/src/main/res/values-zh/translations.xml +++ b/features/createroom/impl/src/main/res/values-zh/translations.xml @@ -8,7 +8,7 @@ "新房间" "新空间" "仅限受邀人员加入。" - "私密" + "私有" "任何人都能找到此房间。 你可以随时在房间设置中更改。" "任何人都可以加入,包括你的组织之外的人员。" @@ -18,7 +18,7 @@ "%1$s 中的任何人都可以加入,但其他人必须申请访问。" "申请加入" "仅限受邀人员加入。" - "私密" + "私有" "任何人都可以加入,包括你的组织之外的人员。" "公共" "%1$s 中的任何人都可以加入。" diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index 09afd38f90..ebd3cdcdc2 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -57,6 +57,7 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom 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.lambdaRecorder import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -522,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { @Test fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val createRoomLambda = lambdaRecorder> { Result.success(A_ROOM_ID) } val matrixClient = createMatrixClient().apply { createRoomResult = createRoomLambda @@ -544,6 +548,38 @@ class ConfigureRoomPresenterTest : RobolectricTest() { } } + @Test + fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest { + val presenter = createConfigureRoomPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private) + val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last() + assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest { + val presenter = createConfigureRoomPresenter( + isSpace = true, + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last() + assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun TurbineTestContext.initialState(): ConfigureRoomState { skipItems(1) return awaitItem() @@ -579,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), isKnockFeatureEnabled: Boolean = true, mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }), + sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, + ), ) = ConfigureRoomPresenter( isSpace = isSpace, initialParentSpaceId = initialParenSpaceId, diff --git a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt similarity index 88% rename from features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt rename to features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt index a28e47e5a0..430386f0f4 100644 --- a/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/api/FakeCreateRoomEntryPoint.kt +++ b/features/createroom/test/src/main/kotlin/io/element/android/features/createroom/test/FakeCreateRoomEntryPoint.kt @@ -6,10 +6,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.createroom.api +package io.element.android.features.createroom.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/deactivation/impl/build.gradle.kts b/features/deactivation/impl/build.gradle.kts index bca144071b..c0a38cabab 100644 --- a/features/deactivation/impl/build.gradle.kts +++ b/features/deactivation/impl/build.gradle.kts @@ -28,7 +28,6 @@ setupDependencyInjection() dependencies { implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt similarity index 91% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt index cc1f5093fd..16bed4f04a 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvent.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationEvent.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl sealed interface AccountDeactivationEvent { data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvent diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt similarity index 95% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt index 44be7b5d53..a3b648a03d 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationNode.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt similarity index 98% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt index 11e658aa7f..3dd527bc9e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt similarity index 94% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt index ebf9fec3c4..73ddf6857e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import android.os.Parcelable import io.element.android.libraries.architecture.AsyncAction diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt similarity index 97% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt index b5ab948b05..49bb01f71e 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStatePreviewParam.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationStatePreviewParam.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt similarity index 97% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt index d86eae41ff..4c256bf3b4 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationView.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,7 +23,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -45,8 +44,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.deactivation.impl.R -import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog +import io.element.android.features.deactivation.impl.ui.AccountDeactivationActionDialog import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.form.textFieldState @@ -69,7 +67,6 @@ import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountDeactivationView( state: AccountDeactivationState, diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt similarity index 93% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt index c93d519e67..c0c62cf6cc 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt similarity index 96% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt index 84037d7eb1..f4862d0df7 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationActionDialog.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl.ui +package io.element.android.features.deactivation.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt similarity index 94% rename from features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt rename to features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt index ab9d87c543..7e255b0b5b 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/deactivation/impl/ui/AccountDeactivationConfirmationDialog.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl.ui +package io.element.android.features.deactivation.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/features/deactivation/impl/src/main/res/values-ro/translations.xml b/features/deactivation/impl/src/main/res/values-ro/translations.xml index 6176b4584e..48ccaa5310 100644 --- a/features/deactivation/impl/src/main/res/values-ro/translations.xml +++ b/features/deactivation/impl/src/main/res/values-ro/translations.xml @@ -3,12 +3,12 @@ "Vă rugăm să confirmați că doriți să vă ștergeți contul. Această acțiune nu poate fi anulată." "Ștergeți toate mesajele mele" "Avertisment: este posibil ca viitorii utilizatori să vadă conversații incomplete." - "Ștergerea contului dumneavoastră este %1$s, acesta va:" + "Ștergerea contului dvs. este %1$s. Această acțiune:" "ireversibilă" - "%1$s contul dumneavoastră (nu vă puteți conecta din nou, iar ID-ul dvs. nu poate fi reutilizat)." - "Dezactivați permanent" - "Îndepărta din toate camerele de chat." - "Șterge informațiile contului dumneavoastră de pe serverul nostru de identitate." - "Mesajele dumneavoastră vor fi în continuare vizibile pentru utilizatorii înregistrați, dar nu vor fi disponibile pentru utilizatorii noi sau neînregistrați dacă alegeți să le ștergeți." + "%1$s contul dvs. (nu vă puteți conecta din nou, iar ID-ul dvs. nu poate fi reutilizat)." + "Va dezactiva permanent" + "Vă va îndepărta din toate camerele." + "Va șterge informațiile contului dvs. de pe serverul de identitate." + "Mesajele dvs. vor fi în continuare vizibile pentru utilizatorii înregistrați, dar nu vor fi disponibile pentru utilizatorii noi sau neînregistrați dacă alegeți să le ștergeți." "Ștergeți contul" diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt similarity index 99% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt index d6402c2f59..8a9aac510b 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt similarity index 98% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt index 5c650fea9d..4da3f236a9 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/AccountDeactivationViewTest.kt @@ -8,7 +8,7 @@ @file:OptIn(ExperimentalTestApi::class) -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.activity.ComponentActivity import androidx.compose.ui.test.AndroidComposeUiTest @@ -16,7 +16,6 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runAndroidComposeUiTest -import io.element.android.features.deactivation.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_PASSWORD diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt similarity index 95% rename from features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt rename to features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt index a9fdf3ff36..0a6fbb801c 100644 --- a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPointTest.kt +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/deactivation/impl/DefaultAccountDeactivationEntryPointTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.logout.impl +package io.element.android.features.deactivation.impl import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext diff --git a/features/enterprise/api/build.gradle.kts b/features/enterprise/api/build.gradle.kts index 5e7f81e4b4..1c541c5080 100644 --- a/features/enterprise/api/build.gradle.kts +++ b/features/enterprise/api/build.gradle.kts @@ -17,5 +17,4 @@ dependencies { implementation(projects.libraries.compound) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) } 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 94f455f2ff..aff9a3ae5c 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 @@ -29,4 +29,10 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean + + /** + * Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one, + * nor to make an existing room or space public. + */ + suspend fun arePublicRoomsDisabledByHomeserver(): Boolean } diff --git a/features/enterprise/impl-foss/build.gradle.kts b/features/enterprise/impl-foss/build.gradle.kts index 2585d00d23..a600920586 100644 --- a/features/enterprise/impl-foss/build.gradle.kts +++ b/features/enterprise/impl-foss/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) implementation(libs.serialization.json) testCommonDependencies(libs) 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 02fa9b282c..e4e2611bc7 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 @@ -15,6 +15,7 @@ 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.androidutils.json.JsonProvider +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.mapCatchingExceptions import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.api.ClientUrlContentFetcher @@ -23,6 +24,7 @@ import io.element.android.libraries.matrix.api.accountprovider.AccountProvider import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import timber.log.Timber @@ -31,15 +33,16 @@ import timber.log.Timber class DefaultEnterpriseService( private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory, private val jsonProvider: JsonProvider, + private val dispatchers: CoroutineDispatchers, ) : EnterpriseService { override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override fun accountProviderAllowList(): List = emptyList() override fun canConnectToAnyAccountProvider(): Boolean = true override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true - override suspend fun isElementProEnforced(serverName: String): Boolean { - val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false } - return temporaryMatrixClient.use { client -> + override suspend fun isElementProEnforced(serverName: String): Boolean = withContext(dispatchers.io) { + val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return@withContext false } + temporaryMatrixClient.use { client -> val baseUrl = serverName.ensureProtocol().removeSuffix("/") // We'll always perform a network request here since we're not interested in any cached value. client.getUrl("$baseUrl/.well-known/element/element.json") diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt index 3ac10d8a08..8a528257a6 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt @@ -18,4 +18,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService { override suspend fun tweakMasUrl(url: String): String = url override suspend fun isElementCallAvailable(): Boolean = true override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false } diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index f43284e0bd..8119b8f53e 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -21,19 +21,21 @@ import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderGeneric import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.Test class DefaultEnterpriseServiceTest { @Test - fun accountProviderAllowList() { + fun accountProviderAllowList() = runTest { val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty() } @Test - fun `canConnectToAnyAccountProvider is always true`() { + fun `canConnectToAnyAccountProvider is always true`() = runTest { val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue() } @@ -132,11 +134,12 @@ class DefaultEnterpriseServiceTest { closeLambda.assertions().isCalledOnce() } - private fun createDefaultEnterpriseService( + private fun TestScope.createDefaultEnterpriseService( client: FakeTemporaryMatrixClient = FakeTemporaryMatrixClient(), jsonProvider: JsonProvider = { Json } ) = DefaultEnterpriseService( temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(createResult = { Result.success(client) }), jsonProvider = jsonProvider, + dispatchers = testCoroutineDispatchers(), ) } diff --git a/features/enterprise/test/build.gradle.kts b/features/enterprise/test/build.gradle.kts index 99dbe733dd..c37fc53de3 100644 --- a/features/enterprise/test/build.gradle.kts +++ b/features/enterprise/test/build.gradle.kts @@ -18,6 +18,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.compound) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.wellknown.api) implementation(projects.tests.testutils) } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt index e198fca867..a461b87c95 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt @@ -16,6 +16,7 @@ class FakeSessionEnterpriseService( private val isElementCallAvailableResult: () -> Boolean = { lambdaError() }, private val tweakMasUrlResult: (String) -> String = { lambdaError() }, private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() }, + private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() }, ) : SessionEnterpriseService { override suspend fun init() { } @@ -31,4 +32,8 @@ class FakeSessionEnterpriseService( override suspend fun isEncryptionDisabledByHomeserver(): Boolean { return isEncryptionDisabledResult() } + + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean { + return arePublicRoomsDisabledResult() + } } diff --git a/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt b/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt index 95b8c43808..bbc3915c86 100644 --- a/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt +++ b/features/forward/api/src/main/kotlin/io/element/android/features/forward/api/ForwardEntryPoint.kt @@ -23,7 +23,7 @@ interface ForwardEntryPoint : FeatureEntryPoint { } data class Params( - val eventId: EventId, + val eventIds: List, val timelineProvider: TimelineProvider, ) : NodeInputs diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt index 97256c6576..e46e268a55 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPoint.kt @@ -27,7 +27,7 @@ class DefaultForwardEntryPoint : ForwardEntryPoint { buildContext = buildContext, plugins = listOf( ForwardMessagesNode.Inputs( - eventId = params.eventId, + eventIds = params.eventIds, timelineProvider = params.timelineProvider, ), callback, diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt index 1c565a390a..6d3bc60414 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesNode.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode @@ -40,6 +41,7 @@ class ForwardMessagesNode( @Assisted plugins: List, presenterFactory: ForwardMessagesPresenter.Factory, private val roomSelectEntryPoint: RoomSelectEntryPoint, + private val sessionId: SessionId, ) : ParentNode( navModel = PermanentNavModel( navTargets = setOf(NavTarget), @@ -52,29 +54,35 @@ class ForwardMessagesNode( object NavTarget : Parcelable data class Inputs( - val eventId: EventId, + val eventIds: List, val timelineProvider: TimelineProvider, ) : NodeInputs private val inputs = inputs() private val callback: ForwardEntryPoint.Callback = callback() - private val presenter = presenterFactory.create(inputs.eventId.value, inputs.timelineProvider) + private val presenter = presenterFactory.create(inputs.eventIds, inputs.timelineProvider) override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { + override fun onRoomSelected(sessionId: SessionId, roomIds: List) { + // In Forward mode, the session cannot be changed presenter.onRoomSelected(roomIds) } override fun onCancel() { callback.onDone(emptyList()) } + + override fun onSessionVerificationRequired(sessionId: SessionId) { + // Only called in Share mode + } } return roomSelectEntryPoint.createNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = sessionId, mode = RoomSelectMode.Forward, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), diff --git a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt index 6068866653..a36be20ab4 100644 --- a/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt +++ b/features/forward/impl/src/main/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenter.kt @@ -28,22 +28,20 @@ import timber.log.Timber @AssistedInject class ForwardMessagesPresenter( - @Assisted eventId: String, + @Assisted private val eventIds: List, @Assisted private val timelineProvider: TimelineProvider, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : Presenter { - private val eventId: EventId = EventId(eventId) - @AssistedFactory fun interface Factory { - fun create(eventId: String, timelineProvider: TimelineProvider): ForwardMessagesPresenter + fun create(eventIds: List, timelineProvider: TimelineProvider): ForwardMessagesPresenter } private val forwardingActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.forwardEvent(eventId, roomIds) + sessionCoroutineScope.forwardEvents(eventIds, roomIds) } @Composable @@ -60,14 +58,14 @@ class ForwardMessagesPresenter( ) } - private fun CoroutineScope.forwardEvent( - eventId: EventId, + private fun CoroutineScope.forwardEvents( + eventIds: List, roomIds: List, ) = launch { suspend { - timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds) + timelineProvider.getActiveTimeline().forwardEvents(eventIds, roomIds) .onFailure { - Timber.e(it, "Error while forwarding event") + Timber.e(it, "Error while forwarding events") } .getOrThrow() roomIds diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt index 9ee932a246..651b852a57 100644 --- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/DefaultForwardEntryPointTest.kt @@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError @@ -39,13 +40,14 @@ class DefaultForwardEntryPointTest { plugins = plugins, presenterFactory = { _, _ -> createForwardMessagesPresenter() }, roomSelectEntryPoint = FakeRoomSelectEntryPoint(), + sessionId = A_SESSION_ID, ) } val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) = lambdaError() } val params = ForwardEntryPoint.Params( - eventId = AN_EVENT_ID, + eventIds = listOf(AN_EVENT_ID), timelineProvider = FakeTimelineProvider(), ) val result = entryPoint.createNode( @@ -57,7 +59,7 @@ class DefaultForwardEntryPointTest { assertThat(result).isInstanceOf(ForwardMessagesNode::class.java) assertThat(result.plugins).contains( ForwardMessagesNode.Inputs( - eventId = params.eventId, + eventIds = params.eventIds, timelineProvider = params.timelineProvider, ) ) diff --git a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt index 0ff3210f2e..5fda2e252c 100644 --- a/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt +++ b/features/forward/impl/src/test/kotlin/io/element/android/features/forward/impl/ForwardMessagesPresenterTest.kt @@ -44,11 +44,11 @@ class ForwardMessagesPresenterTest { @Test fun `present - forward successful`() = runTest { - val forwardEventLambda = lambdaRecorder { _: EventId, _: List -> + val forwardEventsLambda = lambdaRecorder { _: List, _: List -> Result.success(Unit) } val timeline = FakeTimeline().apply { - this.forwardEventLambda = forwardEventLambda + this.forwardEventsLambda = forwardEventsLambda } val room = FakeJoinedRoom(liveTimeline = timeline) val presenter = createForwardMessagesPresenter(fakeRoom = room) @@ -62,17 +62,17 @@ class ForwardMessagesPresenterTest { assertThat(forwardingState.forwardAction.isLoading()).isTrue() val successfulForwardState = awaitItem() assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId))) - forwardEventLambda.assertions().isCalledOnce() + forwardEventsLambda.assertions().isCalledOnce() } } @Test fun `present - select a room and forward failed, then clear`() = runTest { - val forwardEventLambda = lambdaRecorder { _: EventId, _: List -> + val forwardEventsLambda = lambdaRecorder { _: List, _: List -> Result.failure(IllegalStateException("error")) } val timeline = FakeTimeline().apply { - this.forwardEventLambda = forwardEventLambda + this.forwardEventsLambda = forwardEventsLambda } val room = FakeJoinedRoom(liveTimeline = timeline) val presenter = createForwardMessagesPresenter(fakeRoom = room) @@ -88,16 +88,16 @@ class ForwardMessagesPresenterTest { // Then clear error failedForwardState.eventSink(ForwardMessagesEvent.ClearError) assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() - forwardEventLambda.assertions().isCalledOnce() + forwardEventsLambda.assertions().isCalledOnce() } } } fun TestScope.createForwardMessagesPresenter( - eventId: EventId = AN_EVENT_ID, + eventIds: List = listOf(AN_EVENT_ID), fakeRoom: FakeJoinedRoom = FakeJoinedRoom(), ) = ForwardMessagesPresenter( - eventId = eventId.value, + eventIds = eventIds, timelineProvider = LiveTimelineProvider(fakeRoom), sessionCoroutineScope = this, ) diff --git a/features/forward/test/build.gradle.kts b/features/forward/test/build.gradle.kts index bf9db05e34..a3d8428be2 100644 --- a/features/forward/test/build.gradle.kts +++ b/features/forward/test/build.gradle.kts @@ -15,7 +15,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.features.forward.api) implementation(projects.tests.testutils) } diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.kt new file mode 100644 index 0000000000..95c801d009 --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/SessionVerificationEntryPoint.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.ftue.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +/** + * Entry point to the session verification flow displayed during the FTUE, to be used outside of the FTUE. + * As in the FTUE, the user cannot go back from this flow: they have to verify the session or to sign out. + */ +interface SessionVerificationEntryPoint : FeatureEntryPoint { + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node + + interface Callback : Plugin { + /** + * Called when the session has been verified. + */ + fun onDone() + } +} diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index 49b3baa71b..1ef41bcae2 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.testtags) implementation(projects.features.analytics.api) implementation(projects.features.logout.api) implementation(projects.features.securebackup.api) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.kt new file mode 100644 index 0000000000..7959391787 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPoint.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.ftue.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.ftue.api.SessionVerificationEntryPoint +import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultSessionVerificationEntryPoint : SessionVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: SessionVerificationEntryPoint.Callback, + ): Node { + val flowNodeCallback = object : FtueSessionVerificationFlowNode.Callback { + override fun onDone() { + callback.onDone() + } + } + return parentNode.createNode(buildContext, listOf(flowNodeCallback)) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt index f19c323d52..7fd2770fb3 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt @@ -20,16 +20,19 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState +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 @Inject class ChooseSelfVerificationModePresenter( + private val matrixClient: MatrixClient, private val encryptionService: EncryptionService, private val directLogoutPresenter: Presenter, ) : Presenter { @Composable override fun present(): ChooseSelfVerificationModeState { + val currentUser by matrixClient.userProfile.collectAsState() val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState() val canUseRecoveryKey by produceState>(AsyncData.Uninitialized) { encryptionService.recoveryStateStateFlow @@ -72,6 +75,7 @@ class ChooseSelfVerificationModePresenter( } return ChooseSelfVerificationModeState( + currentUser = currentUser, buttonsState = buttonsState, directLogoutState = directLogoutState, eventSink = ::handleEvent, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt index 0512636fca..98dbc340e4 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt @@ -10,8 +10,10 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.user.MatrixUser data class ChooseSelfVerificationModeState( + val currentUser: MatrixUser, val buttonsState: AsyncData, val directLogoutState: DirectLogoutState, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt index 1af6786289..34125cb50b 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt @@ -11,6 +11,8 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser class ChooseSelfVerificationModeStatePreviewParam : PreviewParameterProvider { @@ -43,10 +45,13 @@ class ChooseSelfVerificationModeStatePreviewParam : fun aChooseSelfVerificationModeState( buttonsState: AsyncData = AsyncData.Success(aButtonsState()), + currentUser: MatrixUser = aMatrixUser(), + eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {}, ) = ChooseSelfVerificationModeState( + currentUser = currentUser, buttonsState = buttonsState, directLogoutState = aDirectLogoutState(), - eventSink = {}, + eventSink = eventSink, ) fun aButtonsState( diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index 96d6b82d47..d58f83200c 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -12,10 +12,10 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -36,13 +36,19 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813 + */ @Composable fun ChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, @@ -63,7 +69,7 @@ fun ChooseSelfVerificationModeView( title = {}, actions = { TextButton( - text = stringResource(CommonStrings.action_signout), + text = stringResource(R.string.screen_identity_confirmation_sign_out), onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) } ) } @@ -77,6 +83,7 @@ fun ChooseSelfVerificationModeView( subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle) ) }, + isScrollable = true, footer = { ChooseSelfVerificationModeButtons( state = state, @@ -86,20 +93,31 @@ fun ChooseSelfVerificationModeView( ) } ) { - Row( + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text( - modifier = Modifier - .clickable(onClick = onLearnMore) - .padding(vertical = 4.dp, horizontal = 16.dp) - .semantics { - // Note: there is no Role.Link, so we use Role.Button for better accessibility support - role = Role.Button - }, - text = stringResource(CommonStrings.action_learn_more), - style = ElementTheme.typography.fontBodyLgMedium + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier + .clickable(onClick = onLearnMore) + .padding(vertical = 4.dp, horizontal = 16.dp) + .semantics { + // Note: there is no Role.Link, so we use Role.Button for better accessibility support + role = Role.Button + }, + text = stringResource(CommonStrings.action_learn_more), + style = ElementTheme.typography.fontBodyLgMedium + ) + } + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = state.currentUser, + mode = AccountInfoCardMode.Simple, + ), ) } } @@ -127,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.screen_identity_use_another_device), onClick = onUseAnotherDevice, + leadingIcon = IconSource.Vector(CompoundIcons.Devices()), ) } if (state.buttonsState.data.canUseRecoveryKey) { diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 8e208278a0..6cd117eb59 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt new file mode 100644 index 0000000000..b9248b3bbf --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultSessionVerificationEntryPointTest.kt @@ -0,0 +1,63 @@ +/* + * 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.ftue.impl + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.google.common.truth.Truth.assertThat +import io.element.android.features.ftue.api.SessionVerificationEntryPoint +import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode +import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultSessionVerificationEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node builder`() = runTest { + val entryPoint = DefaultSessionVerificationEntryPoint() + val parentNode = TestParentNode.create { buildContext, plugins -> + FtueSessionVerificationFlowNode( + buildContext = buildContext, + plugins = plugins, + outgoingVerificationEntryPoint = object : OutgoingVerificationEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: OutgoingVerificationEntryPoint.Params, + callback: OutgoingVerificationEntryPoint.Callback, + ) = lambdaError() + }, + secureBackupEntryPoint = object : SecureBackupEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + params: SecureBackupEntryPoint.Params, + callback: SecureBackupEntryPoint.Callback, + ) = lambdaError() + }, + ) + } + val callback = object : SessionVerificationEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback) + assertThat(result).isInstanceOf(FtueSessionVerificationFlowNode::class.java) + } +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt index 8ceb683993..78f257daf0 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt @@ -14,7 +14,13 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -34,6 +40,26 @@ class ChooseSessionVerificationModePresenterTest { } } + @Test + fun `present - current user is exposed`() = runTest { + val presenter = createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + ), + ) + presenter.test { + assertThat(awaitItem().currentUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + ) + ) + } + } + @Test fun `present - state is relayed from EncryptionService, order 1`() = runTest { val encryptionService = FakeEncryptionService() @@ -126,9 +152,11 @@ class ChooseSessionVerificationModePresenterTest { } private fun createPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), encryptionService: FakeEncryptionService = FakeEncryptionService(), directLogoutPresenter: Presenter = Presenter { aDirectLogoutState() } ) = ChooseSelfVerificationModePresenter( + matrixClient = matrixClient, encryptionService = encryptionService, directLogoutPresenter = directLogoutPresenter, ) diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt index 66e2eb38fe..419c8d3371 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() { } } + @Test + fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(eventSink = eventsRecorder), + ) + clickOn(R.string.screen_identity_confirmation_sign_out) + eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut) + } + private fun AndroidComposeUiTest.setChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, onLearnMoreClick: () -> Unit = EnsureNeverCalled(), diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 1c6a0a906e..81c467246f 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -32,5 +33,7 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToEnterRecoveryKey() fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() + fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } } diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 42440934dd..7fcc2dde32 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -45,21 +45,15 @@ dependencies { implementation(projects.libraries.indicator.api) implementation(projects.libraries.deeplink.api) implementation(projects.libraries.fullscreenintent.api) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.features.announcement.api) implementation(projects.features.invite.api) - implementation(projects.features.networkmonitor.api) - implementation(projects.features.logout.api) implementation(projects.features.leaveroom.api) implementation(projects.features.rageshake.api) implementation(projects.services.analytics.api) implementation(libs.androidx.datastore.preferences) - implementation(libs.haze) - implementation(libs.haze.materials) implementation(projects.features.preferences.impl) implementation(projects.features.reportroom.api) implementation(projects.features.rolesandpermissions.api) @@ -69,18 +63,14 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.features.announcement.test) testImplementation(projects.features.invite.test) - testImplementation(projects.features.logout.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.indicator.test) - testImplementation(projects.libraries.permissions.noop) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) - testImplementation(projects.services.toolbox.test) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index b29dc788a7..f4620b0f09 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -8,8 +8,8 @@ package io.element.android.features.home.impl -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -32,11 +32,7 @@ class CurrentUserWithNeighborsBuilder { matrixUser } else { // Use the data from the DB - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - ) + it.toMatrixUser() } } .let { sessionList -> diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 7d2bb09d30..7d071cf90d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -8,9 +8,8 @@ package io.element.android.features.home.impl -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent - data class SwitchToAccount(val sessionId: SessionId) : HomeEvent + data object DismissMultiAccountAnnouncement : HomeEvent + data object DismissSwipeAvatarAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index 20f27f67bd..fda28e0fa0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -40,7 +40,6 @@ import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint import io.element.android.features.leaveroom.api.LeaveRoomRenderer -import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.reportroom.api.ReportRoomEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesEntryPoint import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType @@ -83,7 +82,6 @@ class HomeFlowNode( private val inviteFriendsUseCase: InviteFriendsUseCase, private val analyticsService: AnalyticsService, private val acceptDeclineInviteView: AcceptDeclineInviteView, - private val directLogoutView: DirectLogoutView, private val reportRoomEntryPoint: ReportRoomEntryPoint, private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint, private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, @@ -223,6 +221,8 @@ class HomeFlowNode( homeState = state, onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, + onAddAccountClick = callback::navigateToAddAccount, + onSwitchToAccount = callback::navigateToSession, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, @@ -248,7 +248,6 @@ class HomeFlowNode( ) } ) - directLogoutView.Render(state.directLogoutState) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index 1e49b1aa70..e0c7829b0f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -19,19 +19,26 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +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.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.totalUnreadCountFlow import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Inject @@ -42,16 +49,22 @@ class HomePresenter( private val indicatorService: IndicatorService, private val roomListPresenter: Presenter, private val homeSpacesPresenter: Presenter, - private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val sessionStore: SessionStore, + private val announcementService: AnnouncementService, + private val featureFlagService: FeatureFlagService, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() @Composable override fun present(): HomeState { - val coroutineState = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() + val totalUnreadCount by remember { client.totalUnreadCountFlow() } + // The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition. + .collectAsState(initial = remember { client.totalUnreadCount().getOrDefault(0).toInt() }) val currentUserAndNeighbors by remember { combine( client.userProfile, @@ -61,6 +74,30 @@ class HomePresenter( }.collectAsState(initial = persistentListOf(matrixUser)) val isOnline by syncService.isOnline.collectAsState() val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) + val showMultiAccountAnnouncement by remember { + combine( + announcementService.announcementsToShowFlow(), + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount), + sessionStore.sessionsFlow(), + ) { announcements, isMultiAccountEnabled, sessions -> + // Do not announce the feature to users who already have several accounts + isMultiAccountEnabled && sessions.size == 1 && announcements.contains(Announcement.MultiAccount) + } + }.collectAsState(false) + LaunchedEffect(Unit) { + // The user already uses several accounts, so the feature does not need to be announced anymore, + // even if they later remove accounts. + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + announcements.contains(Announcement.MultiAccount) && sessions.size > 1 + } + .filter { it } + .collect { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) + } + } val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } @@ -75,15 +112,18 @@ class HomePresenter( } // Avatar indicator val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() - val directLogoutState = logoutPresenter.present() fun handleEvent(event: HomeEvent) { when (event) { is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } - is HomeEvent.SwitchToAccount -> coroutineState.launch { - sessionStore.setLatestSession(event.sessionId.value) + // The user is switching account, which destroys this presenter, so use the app scope to not be cancelled. + HomeEvent.DismissSwipeAvatarAnnouncement -> appCoroutineScope.launch { + announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) + } + HomeEvent.DismissMultiAccountAnnouncement -> coroutineScope.launch { + announcementService.onAnnouncementDismissed(Announcement.MultiAccount) } } } @@ -98,7 +138,8 @@ class HomePresenter( homeSpacesState = homeSpacesState, snackbarMessage = snackbarMessage, canReportBug = canReportBug, - directLogoutState = directLogoutState, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, + totalUnreadCount = totalUnreadCount, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index ae59ef8eb9..eb8e3ff2f1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -11,7 +11,6 @@ package io.element.android.features.home.impl import io.element.android.features.home.impl.roomlist.RoomListState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spaces.HomeSpacesState -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -29,7 +28,8 @@ data class HomeState( val homeSpacesState: HomeSpacesState, val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, - val directLogoutState: DirectLogoutState, + val showMultiAccountAnnouncement: Boolean, + val totalUnreadCount: Int, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index 14ab6dc7f6..4a82166d3e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -16,8 +16,6 @@ import io.element.android.features.home.impl.roomlist.aRoomsContentState import io.element.android.features.home.impl.roomlist.generateRoomListRoomSummaryList import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -50,6 +48,8 @@ open class HomeStatePreviewParam : PreviewParameterProvider { homeSpacesState = aHomeSpacesState( spaceRooms = emptyList(), ), + ) + aHomeState( + showMultiAccountAnnouncement = true, ) } @@ -63,7 +63,8 @@ internal fun aHomeState( roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, - directLogoutState: DirectLogoutState = aDirectLogoutState(), + showMultiAccountAnnouncement: Boolean = false, + totalUnreadCount: Int = 0, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -71,9 +72,10 @@ internal fun aHomeState( hasNetworkConnection = hasNetworkConnection, snackbarMessage = snackbarMessage, canReportBug = canReportBug, - directLogoutState = directLogoutState, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + totalUnreadCount = totalUnreadCount, eventSink = eventSink, ) 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 024d013871..6640120614 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 @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalHazeMaterialsApi::class) - package io.element.android.features.home.impl import androidx.activity.compose.BackHandler @@ -24,8 +22,6 @@ 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 import androidx.compose.material3.FabPosition import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -40,14 +36,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.materials.HazeMaterials -import dev.chrisbanes.haze.rememberHazeState import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.home.impl.components.HomeTopBar +import io.element.android.features.home.impl.components.MultiAccountAnnouncementBottomSheet import io.element.android.features.home.impl.components.RoomListContentView import io.element.android.features.home.impl.components.RoomListMenuAction import io.element.android.features.home.impl.model.RoomListRoomSummary @@ -77,6 +69,7 @@ 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.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -85,6 +78,8 @@ fun HomeView( homeState: HomeState, onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, + onAddAccountClick: () -> Unit, + onSwitchToAccount: (SessionId) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -119,6 +114,13 @@ fun HomeView( ) } + if (homeState.showMultiAccountAnnouncement) { + MultiAccountAnnouncementBottomSheet( + onDismiss = { homeState.eventSink(HomeEvent.DismissMultiAccountAnnouncement) }, + onAddAccountClick = { if (firstThrottler.canHandle()) onAddAccountClick() }, + ) + } + leaveRoomView() HomeScaffold( @@ -130,6 +132,7 @@ fun HomeView( onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, + onSwitchToAccount = onSwitchToAccount, ) if (state.globalSearchState.isEnabled) { @@ -157,7 +160,6 @@ fun HomeView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeScaffold( state: HomeState, @@ -168,6 +170,7 @@ private fun HomeScaffold( onStartChatClick: () -> Unit, onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, + onSwitchToAccount: (SessionId) -> Unit, modifier: Modifier = Modifier, ) { fun onRoomClick(room: RoomListRoomSummary) { @@ -190,12 +193,11 @@ private fun HomeScaffold( } } - val hazeState = rememberHazeState() val roomsLazyListState = rememberLazyListState() val spacesLazyListState = rememberLazyListState() Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = modifier, topBar = { HomeTopBar( selectedNavigationItem = state.currentHomeNavigationBarItem, @@ -215,18 +217,16 @@ private fun HomeScaffold( }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, - onAccountSwitch = { - state.eventSink(HomeEvent.SwitchToAccount(it)) + onAccountSwitch = { sessionId -> + // The user has switched account by swiping their avatar, so they know the gesture. + state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement) + onSwitchToAccount(sessionId) }, scrollBehavior = scrollBehavior, displayFilters = state.displayRoomListFilters, filtersState = roomListState.filtersState, spaceFiltersState = roomListState.spaceFiltersState, canReportBug = state.canReportBug, - modifier = Modifier.hazeEffect( - state = hazeState, - style = HazeMaterials.thick(), - ) ) }, floatingActionButton = { @@ -265,6 +265,7 @@ private fun HomeScaffold( } } }, + totalUnreadCount = state.totalUnreadCount, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -280,44 +281,50 @@ private fun HomeScaffold( val contentPadding = PaddingValues( bottom = 96.dp, ) - when (state.currentHomeNavigationBarItem) { - HomeNavigationBarItem.Chats -> { - RoomListContentView( - contentState = roomListState.contentState, - filtersState = roomListState.filtersState, - spaceFiltersState = roomListState.spaceFiltersState, - lazyListState = roomsLazyListState, - hideInvitesAvatars = roomListState.hideInvitesAvatars, - eventSink = roomListState.eventSink, - onSetUpRecoveryClick = onSetUpRecoveryClick, - onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, - onRoomClick = ::onRoomClick, - onCreateRoomClick = onStartChatClick, - contentPadding = lazyColumnContentPadding + contentPadding, - modifier = Modifier - .padding(outerPadding) - .consumeWindowInsets(outerPadding) - .hazeSource(state = hazeState) - ) - SpaceFiltersView(roomListState.spaceFiltersState) - } - HomeNavigationBarItem.Spaces -> { - HomeSpacesView( - modifier = Modifier - .fillMaxSize() - .padding(outerPadding) - .consumeWindowInsets(outerPadding) - .hazeSource(state = hazeState), - contentPadding = lazyColumnContentPadding + contentPadding, - state = state.homeSpacesState, - lazyListState = spacesLazyListState, - onSpaceClick = { spaceId -> - onRoomClick(spaceId) - }, - onCreateSpaceClick = onCreateSpaceClick, - // TODO use actual callbacks for this - onExploreClick = {}, - ) + // The nested scroll connection is set on the content and not on the Scaffold, else the top bar would + // consume the scroll events of its own content. + Box( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + when (state.currentHomeNavigationBarItem) { + HomeNavigationBarItem.Chats -> { + RoomListContentView( + contentState = roomListState.contentState, + filtersState = roomListState.filtersState, + spaceFiltersState = roomListState.spaceFiltersState, + lazyListState = roomsLazyListState, + hideInvitesAvatars = roomListState.hideInvitesAvatars, + eventSink = roomListState.eventSink, + onSetUpRecoveryClick = onSetUpRecoveryClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = ::onRoomClick, + onCreateRoomClick = onStartChatClick, + contentPadding = lazyColumnContentPadding + contentPadding, + modifier = Modifier + .padding(outerPadding) + .consumeWindowInsets(outerPadding) + ) + SpaceFiltersView(roomListState.spaceFiltersState) + } + HomeNavigationBarItem.Spaces -> { + HomeSpacesView( + modifier = Modifier + .fillMaxSize() + .padding(outerPadding) + .consumeWindowInsets(outerPadding), + contentPadding = lazyColumnContentPadding + contentPadding, + state = state.homeSpacesState, + lazyListState = spacesLazyListState, + onSpaceClick = { spaceId -> + onRoomClick(spaceId) + }, + onCreateSpaceClick = onCreateSpaceClick, + // TODO use actual callbacks for this + onExploreClick = {}, + ) + } } } }, @@ -339,9 +346,9 @@ private fun HomeFloatingActionButton( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun HomeBottomBar( + totalUnreadCount: Int, currentHomeNavigationBarItem: HomeNavigationBarItem, onItemClick: (HomeNavigationBarItem) -> Unit, modifier: Modifier = Modifier, @@ -362,6 +369,7 @@ private fun HomeBottomBar( tooltipLabel = stringResource(item.labelRes), isSelected = isSelected, onClick = { onItemClick(item) }, + counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 } ) } } @@ -376,6 +384,8 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta homeState = state, onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -396,6 +406,8 @@ internal fun HomeViewA11yPreview() = ElementPreview { homeState = aHomeState(), onRoomClick = { _, _ -> }, onSettingsClick = {}, + onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt index 95826665fe..3feabca8b0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/BatteryOptimizationBanner.kt @@ -16,13 +16,11 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent -import io.element.android.libraries.push.api.battery.BatteryOptimizationState -import io.element.android.libraries.push.api.battery.aBatteryOptimizationState @Composable internal fun BatteryOptimizationBanner( - state: BatteryOptimizationState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { Announcement( @@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner( description = stringResource(R.string.banner_battery_optimization_content_android), type = AnnouncementType.Actionable( actionText = stringResource(R.string.banner_battery_optimization_submit_android), - onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, + onActionClick = onActionClick, + onDismissClick = onDismissClick, ), ) } @@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner( @Composable internal fun BatteryOptimizationBannerPreview() = ElementPreview { BatteryOptimizationBanner( - state = aBatteryOptimizationState(), + onActionClick = {}, + onDismissClick = {}, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt index f2826415d2..ae5a25a623 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/FullScreenIntentPermissionBanner.kt @@ -16,14 +16,12 @@ import io.element.android.libraries.designsystem.components.Announcement import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState -import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.ui.strings.CommonStrings @Composable fun FullScreenIntentPermissionBanner( - state: FullScreenIntentPermissionsState, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { Announcement( @@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner( description = stringResource(R.string.full_screen_intent_banner_message), type = AnnouncementType.Actionable( actionText = stringResource(CommonStrings.action_continue), - onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, + onDismissClick = onDismissClick, + onActionClick = onActionClick, ), modifier = modifier.roomListBannerPadding(), ) @@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner( @Composable internal fun FullScreenIntentPermissionBannerPreview() { ElementPreview { - FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) + FullScreenIntentPermissionBanner( + onActionClick = {}, + onDismissClick = {}, + ) } } 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 9f4a2403f7..291b9f3c00 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 @@ -9,9 +9,11 @@ package io.element.android.features.home.impl.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -25,7 +27,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -44,18 +45,10 @@ 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.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 @@ -99,8 +92,8 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.filter -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeTopBar( selectedNavigationItem: HomeNavigationBarItem, @@ -298,11 +291,15 @@ private fun NavigationIcon( onAccountSwitch: (SessionId) -> Unit, onClick: () -> Unit, ) { + // Reserve the status emoji slot for all the accounts, else the width of the navigation icon, + // and so the position of the title, would change while scrolling the pager. + val reserveStatusEmojiSlot = currentUserAndNeighbors.any { it.displayedStatus != null } if (currentUserAndNeighbors.size == 1) { AccountIcon( matrixUser = currentUserAndNeighbors.single(), isCurrentAccount = true, showAvatarIndicator = showAvatarIndicator, + reserveStatusEmojiSlot = reserveStatusEmojiSlot, onClick = onClick, ) } else { @@ -311,9 +308,12 @@ private fun NavigationIcon( // Listen to page changes and switch account if needed val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.collect { page -> - latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) - } + snapshotFlow { pagerState.settledPage } + // The current account is on page 1, so there is nothing to switch to + .filter { page -> page != 1 } + .collect { page -> + latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) + } } VerticalPager( state = pagerState, @@ -323,6 +323,7 @@ private fun NavigationIcon( matrixUser = currentUserAndNeighbors[page], isCurrentAccount = page == 1, showAvatarIndicator = page == 1 && showAvatarIndicator, + reserveStatusEmojiSlot = reserveStatusEmojiSlot, onClick = if (page == 1) { onClick } else { @@ -338,95 +339,74 @@ private fun AccountIcon( matrixUser: MatrixUser, isCurrentAccount: Boolean, showAvatarIndicator: Boolean, + reserveStatusEmojiSlot: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier val interactionSource = remember { MutableInteractionSource() } - Box( + Row( modifier = modifier .then(testTag) - .minimumInteractiveComponentSize() .clickable( interactionSource = interactionSource, onClick = onClick, - indication = ripple(bounded = false), + // The ripple is rendered on the avatar only, see below. + indication = null, ), - contentAlignment = Alignment.Center, + verticalAlignment = Alignment.CenterVertically, ) { - val avatarData by remember(matrixUser) { - derivedStateOf { - matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + Box( + modifier = Modifier + .minimumInteractiveComponentSize() + .indication( + interactionSource = interactionSource, + indication = ripple(bounded = false), + ), + contentAlignment = Alignment.Center, + ) { + val avatarData by remember(matrixUser) { + derivedStateOf { + matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar) + } + } + Avatar( + avatarData = avatarData, + avatarType = AvatarType.User, + contentDescription = if (isCurrentAccount) { + if (showAvatarIndicator) { + stringResource(CommonStrings.a11y_settings_with_required_action) + } else { + stringResource(CommonStrings.common_settings) + } + } else { + null + }, + ) + if (showAvatarIndicator) { + RedIndicatorAtom( + modifier = Modifier.align(Alignment.TopEnd) + ) } } - 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) - } else { - stringResource(CommonStrings.common_settings) - } - } else { - null - }, - ) - if (statusEmoji != null) { - StatusEmojiBadge( - emoji = statusEmoji, - modifier = Modifier.align(Alignment.BottomEnd), - ) - } - if (showAvatarIndicator) { - RedIndicatorAtom( - modifier = Modifier.align(Alignment.TopEnd) + if (reserveStatusEmojiSlot) { + StatusEmoji( + emoji = matrixUser.displayedStatus?.toEmojiText(), + modifier = Modifier.offset(x = statusEmojiStartOffset), ) } } } -private val statusEmojiBadgeSize = 20.dp -private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2 -private val statusEmojiBadgeOffset = 8.dp +private val statusEmojiSize = 20.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, - ) - } +// The avatar is centered inside a 48dp touch target, so there is already 8dp of free space +// on its trailing side. Compensate it to end up with a 4dp gap after the avatar itself. +private val statusEmojiStartOffset = 4.dp - (48.dp - AvatarSize.CurrentUserTopBar.dp) / 2 @Composable -private fun StatusEmojiBadge( - emoji: String, +private fun StatusEmoji( + emoji: String?, modifier: Modifier = Modifier, ) { val density = LocalDensity.current @@ -435,21 +415,20 @@ private fun StatusEmojiBadge( Density(density = density.density, fontScale = 1f) } Box( - modifier = modifier - .offset(x = statusEmojiBadgeOffset) - .size(statusEmojiBadgeSize), + modifier = modifier.size(statusEmojiSize), contentAlignment = Alignment.Center, ) { - CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { - Text( - text = emoji, - style = ElementTheme.typography.fontBodyMdRegular, - ) + if (emoji != null) { + CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) { + Text( + text = emoji, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } } } } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarPreview() = ElementPreview { @@ -470,7 +449,6 @@ internal fun HomeTopBarPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview { @@ -491,7 +469,6 @@ internal fun HomeTopBarSpaceFiltersSelectedPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarSpacesPreview() = ElementPreview { @@ -512,7 +489,6 @@ internal fun HomeTopBarSpacesPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { @@ -533,7 +509,6 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarWithStatusPreview() = ElementPreview { @@ -560,7 +535,6 @@ internal fun HomeTopBarWithStatusPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarMultiAccountPreview() = ElementPreview { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt new file mode 100644 index 0000000000..cec1dbf3a0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/MultiAccountAnnouncementBottomSheet.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +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.home.impl.R +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.launch + +/** + * Bottom sheet announcing that several accounts can now be added. + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-43073 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAccountAnnouncementBottomSheet( + onDismiss: () -> Unit, + onAddAccountClick: () -> Unit, +) { + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + ) + val coroutineScope = rememberCoroutineScope() + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + scrollable = false, + ) { + MultiAccountAnnouncementContent( + onAddAccountClick = { + coroutineScope.launch { + sheetState.hide() + onDismiss() + onAddAccountClick() + } + }, + ) + } +} + +@Composable +private fun MultiAccountAnnouncementContent( + onAddAccountClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(all = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BigIcon( + style = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + ) + Spacer(Modifier.height(16.dp)) + Text( + modifier = Modifier + .padding(horizontal = 8.dp) + .semantics { heading() }, + text = stringResource(R.string.screen_multi_account_announcement_title), + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + val path = stringResource(CommonStrings.common_settings) + " > " + stringResource(CommonStrings.common_add_another_account) + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = annotatedTextWithBold( + text = stringResource(R.string.screen_multi_account_announcement_description, path), + boldText = path, + ), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + Button( + text = stringResource(R.string.screen_multi_account_announcement_action), + leadingIcon = IconSource.Vector(CompoundIcons.Plus()), + modifier = Modifier.fillMaxWidth(), + onClick = onAddAccountClick, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@PreviewsDayNight +@Composable +internal fun MultiAccountAnnouncementBottomSheetPreview() = ElementPreview(fillMaxSize = true) { + MultiAccountAnnouncementBottomSheet( + onDismiss = {}, + onAddAccountClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index e06fe7c46d..02288eb8b7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -8,7 +8,12 @@ package io.element.android.features.home.impl.components -import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,16 +21,18 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter @@ -41,10 +48,11 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.features.home.impl.roomlist.RoomListBanner import io.element.android.features.home.impl.roomlist.RoomListContentState import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam import io.element.android.features.home.impl.roomlist.RoomListEvent -import io.element.android.features.home.impl.roomlist.SecurityBannerState +import io.element.android.features.home.impl.roomlist.toRoomListBanner import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState import io.element.android.libraries.designsystem.preview.ElementPreview @@ -54,6 +62,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect +import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent +import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -129,6 +139,9 @@ private fun SkeletonView( } } +/** + * Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=8-4656 + */ @Composable private fun EmptyView( state: RoomListContentState.Empty, @@ -139,34 +152,38 @@ private fun EmptyView( modifier: Modifier = Modifier, ) { Box(modifier.fillMaxSize()) { + val text = buildString { + append(stringResource(R.string.screen_roomlist_empty_title)) + append("\n") + append(stringResource(R.string.screen_roomlist_empty_message)) + } EmptyScaffold( - title = R.string.screen_roomlist_empty_title, - subtitle = R.string.screen_roomlist_empty_message, - action = { + title = null, + subtitle = text, + bottomPart = { + Spacer(modifier = Modifier.height(16.dp)) Button( text = stringResource(CommonStrings.action_start_chat), - leadingIcon = IconSource.Vector(CompoundIcons.Compose()), + leadingIcon = IconSource.Vector(CompoundIcons.Chat()), onClick = onCreateRoomClick, ) }, modifier = Modifier.align(Alignment.Center), ) - Box { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - SecurityBannerState.RecoveryKeyConfirmation -> { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - SecurityBannerState.None -> Unit - } + RoomListBannerContainer( + banner = state.securityBannerState.toRoomListBanner(), + ) { banner -> + RoomListBannerView( + banner = banner, + onActionClick = { + when (banner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + else -> Unit + } + }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, + ) } } } @@ -220,54 +237,56 @@ private fun RoomsViewList( lazyListState: LazyListState, modifier: Modifier = Modifier, ) { + val banner = state.toRoomListBanner() + // The banner slot must never be 0px high, else the LazyColumn considers that it is not visible and it is + // removed from the composition. A new banner would then be added above the visible area of the list. + val bannerSlotMinHeight = with(LocalDensity.current) { 1.toDp() } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> - eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) + // The first item of the list is the banner slot, remove it to get the range of the rooms + val roomsRange = (visibleRange.first - 1).coerceAtLeast(0).. { - item { - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - item { - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - item { - FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - item { - BatteryOptimizationBanner(state = state.batteryOptimizationState) - } - } - state.showNewNotificationSoundBanner -> { - item { - NewNotificationSoundBanner( - onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) - } - } + // The banner slot is always the first item of the list, even when there is no banner to display, + // so that the rooms keep the same index when a banner is added or removed. Since the rooms do not + // have a key, this is required to keep the scroll position. + item { + RoomListBannerContainer( + banner = banner, + modifier = Modifier.heightIn(min = bannerSlotMinHeight), + ) { displayedBanner -> + RoomListBannerView( + banner = displayedBanner, + onActionClick = { + when (displayedBanner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + RoomListBanner.FullScreenIntentPermission -> + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) + RoomListBanner.BatteryOptimization -> + state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) + RoomListBanner.NewNotificationSound, + RoomListBanner.SwipeAvatar -> Unit + } + }, + onDismissClick = { + when (displayedBanner) { + RoomListBanner.SetUpRecovery, + RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner) + RoomListBanner.FullScreenIntentPermission -> + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) + RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) + RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner) + RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner) + } + }, + ) } } - // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // is moved to the top of the list. itemsIndexed( @@ -280,6 +299,7 @@ private fun RoomsViewList( isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE && state.seenRoomInvites.contains(room.roomId), showUnreadCount = state.showUnreadCount, + showAllActivity = state.showAllActivity, onClick = onRoomClick, eventSink = eventSink, ) @@ -290,6 +310,73 @@ private fun RoomsViewList( } } +/** + * Displays the [banner] at the top of the room list, and animates every change: when the banner is + * dismissed, when it is replaced by another banner, and when a banner appears. + * The size of the slot is animated, so that the items below it are not moved abruptly. + * + * @param banner the banner to display, or null if there is no banner to display. + * @param modifier the modifier to apply to the slot. + * @param content renders the given banner. + */ +@Composable +private fun RoomListBannerContainer( + banner: RoomListBanner?, + modifier: Modifier = Modifier, + content: @Composable (RoomListBanner) -> Unit, +) { + AnimatedContent( + targetState = banner, + // Fill the width so that only the height is animated + modifier = modifier.fillMaxWidth(), + transitionSpec = { + fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith + fadeOut(tween(durationMillis = HALF_DURATION_MILLIS)) using + SizeTransform { _, _ -> tween(durationMillis = 2 * HALF_DURATION_MILLIS) } + }, + contentAlignment = Alignment.Center, + label = "RoomListBannerContainer", + ) { targetBanner -> + if (targetBanner != null) { + content(targetBanner) + } + } +} + +private const val HALF_DURATION_MILLIS = 150 + +@Composable +private fun RoomListBannerView( + banner: RoomListBanner, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, +) { + when (banner) { + RoomListBanner.SetUpRecovery -> SetUpRecoveryKeyBanner( + onContinueClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner( + onContinueClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner( + onActionClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner( + onActionClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner( + onDismissClick = onDismissClick, + ) + RoomListBanner.SwipeAvatar -> SwipeAvatarBanner( + onDismissClick = onDismissClick, + ) + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, @@ -298,39 +385,44 @@ private fun EmptyViewForFilterStates( ) { val emptyStateResources = RoomListFiltersEmptyStateResources.fromSelectedFilters(selectedFilters, isSpaceFilterSelected) ?: return EmptyScaffold( - title = emptyStateResources.title, - subtitle = emptyStateResources.subtitle, + title = stringResource(emptyStateResources.title), + subtitle = stringResource(emptyStateResources.subtitle), modifier = modifier, + bottomPart = { + // Keep a spacer so that the content is not too low on the screen + Spacer(modifier = Modifier.height(32.dp)) + } ) } @Composable private fun EmptyScaffold( - @StringRes title: Int, - @StringRes subtitle: Int, + title: String?, + subtitle: String, modifier: Modifier = Modifier, - action: @Composable (ColumnScope.() -> Unit)? = null, + bottomPart: @Composable ColumnScope.() -> Unit, ) { Column( modifier = modifier.padding(horizontal = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + title?.let { + Text( + text = title, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + } Text( - text = stringResource(title), - style = ElementTheme.typography.fontHeadingMdBold, - color = ElementTheme.colors.textPrimary, - textAlign = TextAlign.Center, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(subtitle), + text = subtitle, style = ElementTheme.typography.fontBodyLgRegular, color = ElementTheme.colors.textSecondary, textAlign = TextAlign.Center, ) - Spacer(modifier = Modifier.height(32.dp)) - action?.invoke(this) + bottomPart() } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index 883d8aff76..1f8fe4f2d2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -60,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomName -import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.user.DisplayedStatus @@ -80,6 +79,7 @@ internal fun RoomSummaryRow( onClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, showUnreadCount: Boolean = false, + showAllActivity: Boolean = false, eventSink: (RoomListEvent) -> Unit, ) { Box(modifier = modifier) { @@ -131,7 +131,7 @@ internal fun RoomSummaryRow( isHighlighted = room.isHighlighted, dmUserStatus = room.dmUserStatus, ) - MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount) + MessagePreviewAndIndicatorRow(room = room, showUnreadCount = showUnreadCount, showAllActivity = showAllActivity) } } RoomSummaryDisplayType.KNOCKED -> { @@ -243,7 +243,7 @@ private fun NameAndTimestampRow( text = timestamp ?: "", style = ElementTheme.typography.fontBodySmMedium, color = if (isHighlighted) { - ElementTheme.colors.unreadIndicator + ElementTheme.colors.textActionAccent } else { ElementTheme.colors.roomListRoomMessageDate }, @@ -278,6 +278,7 @@ private fun InviteSubtitle( private fun MessagePreviewAndIndicatorRow( room: RoomListRoomSummary, showUnreadCount: Boolean, + showAllActivity: Boolean, modifier: Modifier = Modifier, ) { Row( @@ -345,38 +346,29 @@ private fun MessagePreviewAndIndicatorRow( // Call and unread Row( modifier = Modifier - .height(16.dp) // Used to force this line to be read aloud earlier than the latest event when using Talkback .zIndex(-1f), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - val tint = if (room.isHighlighted) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary + val tint = if (room.isHighlighted) ElementTheme.colors.textActionAccent else ElementTheme.colors.iconQuaternary if (room.hasRoomCall) { OnGoingCallIcon( color = tint, isAudio = room.activeCallIntent == CallIntent.AUDIO ) } - if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { + val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE + if (isMuted) { NotificationOffIndicatorAtom() } else if (room.numberOfUnreadMentions > 0) { MentionIndicatorAtom() } if (room.hasNewContent) { val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages) - val count = if (showUnreadCount) { - if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) { - room.numberOfUnreadMessages - } else { - room.numberOfUnreadNotifications - } - } else { - null - } UnreadIndicatorAtom( color = tint, - count = count, + count = room.unreadIndicatorCount(showUnreadCount = showUnreadCount, showAllActivity = showAllActivity), contentDescription = contentDescription, ) } @@ -384,6 +376,27 @@ private fun MessagePreviewAndIndicatorRow( } } +/** + * Computes the value to pass to [UnreadIndicatorAtom] as `count`. + * - `null`: nothing is displayed. + * - `0`: dot indicator without a count. + * - `> 0`: the number to display. + */ +internal fun RoomListRoomSummary.unreadIndicatorCount( + showUnreadCount: Boolean, + showAllActivity: Boolean, +): Long? { + val isMuted = userDefinedNotificationMode == RoomNotificationMode.MUTE + return when { + // Show only unread notifications when the room is not muted and there are unread notifications + !isMuted && numberOfUnreadNotifications > 0 -> if (showUnreadCount) numberOfUnreadNotifications else 0 + // Otherwise, show a dot for unread messages when showAllActivity is true and there are unread messages + showAllActivity -> if (numberOfUnreadMessages > 0) 0 else null + // Or don't display anything + else -> null + } +} + @Composable private fun InviteNameAndIndicatorRow( name: String?, @@ -407,9 +420,7 @@ private fun InviteNameAndIndicatorRow( overflow = TextOverflow.Ellipsis ) if (!isInviteSeen) { - UnreadIndicatorAtom( - color = ElementTheme.colors.unreadIndicator - ) + UnreadIndicatorAtom(count = 0) } } } @@ -443,7 +454,7 @@ private fun MentionIndicatorAtom() { modifier = Modifier.size(16.dp), contentDescription = stringResource(CommonStrings.a11y_notifications_new_mentions), imageVector = CompoundIcons.Mention(), - tint = ElementTheme.colors.unreadIndicator, + tint = ElementTheme.colors.iconAccentPrimary, ) } @@ -457,5 +468,7 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP isInviteSeen = data.name == "Bob", onClick = {}, eventSink = {}, + showUnreadCount = data.numberOfUnreadNotifications > 0, + showAllActivity = true, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt new file mode 100644 index 0000000000..bf1df99aec --- /dev/null +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/SwipeAvatarBanner.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.home.impl.R +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun SwipeAvatarBanner( + onDismissClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Announcement( + modifier = modifier.roomListBannerPadding(), + title = stringResource(R.string.banner_swipe_avatar_title), + description = stringResource(R.string.banner_swipe_avatar_content), + type = AnnouncementType.Actionable( + actionText = stringResource(CommonStrings.action_ok), + onActionClick = onDismissClick, + onDismissClick = onDismissClick, + ), + ) +} + +@PreviewsDayNight +@Composable +internal fun SwipeAvatarBannerPreview() = ElementPreview { + SwipeAvatarBanner( + onDismissClick = {}, + ) +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt index c04c3e446b..17b35c3b42 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/model/RoomListRoomSummaryPreviewParam.kt @@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider RoomListBanner.SetUpRecovery + SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation + SecurityBannerState.None -> null +} + +/** + * Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization > + * new-notification-sound > swipe-avatar. At most one renders at a time. + */ +internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? { + return securityBannerState.toRoomListBanner() ?: when { + fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission + batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization + showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound + showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar + else -> null + } +} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt index 4ca87992f9..bcc369d1bc 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListContentStatePreviewParam.kt @@ -30,24 +30,31 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider = aRoomListRoomSummaryList(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(), + showAllActivity: Boolean = false, seenRoomInvites: Set = emptySet(), ) = RoomListContentState.Rooms( securityBannerState = securityBannerState, showNewNotificationSoundBanner = showNewNotificationSoundBanner, + showSwipeAvatarBanner = showSwipeAvatarBanner, showUnreadCount = showUnreadCount, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, batteryOptimizationState = batteryOptimizationState, summaries = summaries, + showAllActivity = showAllActivity, seenRoomInvites = seenRoomInvites.toImmutableSet(), ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt index 49d033682f..d912842b64 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListEvent.kt @@ -16,6 +16,7 @@ sealed interface RoomListEvent { data object DismissRequestVerificationPrompt : RoomListEvent data object DismissBanner : RoomListEvent data object DismissNewNotificationSoundBanner : RoomListEvent + data object DismissSwipeAvatarBanner : RoomListEvent data object ToggleSearchResults : RoomListEvent data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 546092ede6..611f1e8ee2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -47,6 +47,7 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId @@ -55,6 +56,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.push.api.battery.BatteryOptimizationState +import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -63,6 +65,7 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -88,6 +91,7 @@ class RoomListPresenter( private val spaceFiltersPresenter: Presenter, private val globalSearchPresenter: Presenter, private val featureFlagService: FeatureFlagService, + private val sessionStore: SessionStore, ) : Presenter { private val encryptionService = client.encryptionService @@ -111,6 +115,15 @@ class RoomListPresenter( announcements.contains(Announcement.NewNotificationSound) } }.collectAsState(false) + val showSwipeAvatarBanner by remember { + combine( + announcementService.announcementsToShowFlow(), + sessionStore.sessionsFlow(), + ) { announcements, sessions -> + // The swipe gesture on the avatar is only available when there are several accounts + sessions.size >= 2 && announcements.contains(Announcement.SwipeAvatarToSwitchAccount) + } + }.collectAsState(false) // Avatar indicator val hideInvitesAvatar by client.rememberHideInvitesAvatar() @@ -118,6 +131,10 @@ class RoomListPresenter( val contextMenu = remember { mutableStateOf(RoomListState.ContextMenu.Hidden) } val declineInviteMenu = remember { mutableStateOf(RoomListState.DeclineInviteMenu.Hidden) } + val showAllActivity by produceState(initialValue = false) { + featureFlagService.isFeatureEnabledFlow(ShowAllActivityInRoomListFeature).collect { value = it } + } + fun handleEvent(event: RoomListEvent) { when (event) { is RoomListEvent.UpdateVisibleRange -> coroutineScope.launch { @@ -128,6 +145,9 @@ class RoomListPresenter( RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch { announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) } + RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch { + announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) + } RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility) is RoomListEvent.ShowContextMenu -> { coroutineScope.showContextMenu(event, contextMenu) @@ -165,13 +185,15 @@ class RoomListPresenter( val canReportRoom by produceState(false) { value = client.canReportRoom() } val showUnreadCount by produceState(false) { - value = featureFlagService.isFeatureEnabled(FeatureFlags.UnreadIndicatorCount) + featureFlagService.isFeatureEnabledFlow(FeatureFlags.UnreadIndicatorCount).collect { value = it } } val contentState = roomListContentState( securityBannerDismissed, showNewNotificationSoundBanner, + showSwipeAvatarBanner, showUnreadCount, + showAllActivity, ) return RoomListState( @@ -229,7 +251,9 @@ class RoomListPresenter( private fun roomListContentState( securityBannerDismissed: Boolean, showNewNotificationSoundBanner: Boolean, + showSwipeAvatarBanner: Boolean, showUnreadCount: Boolean, + showAllActivity: Boolean, ): RoomListContentState { val roomSummaries by produceState(initialValue = AsyncData.Loading()) { roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) } @@ -258,10 +282,12 @@ class RoomListPresenter( RoomListContentState.Rooms( securityBannerState = securityBannerState, showNewNotificationSoundBanner = showNewNotificationSoundBanner, + showSwipeAvatarBanner = showSwipeAvatarBanner, showUnreadCount = showUnreadCount, fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(), summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(), + showAllActivity = showAllActivity, seenRoomInvites = seenRoomInvites.toImmutableSet(), ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index 4164c56e9d..6d3d85ab56 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -73,8 +73,10 @@ sealed interface RoomListContentState { val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val batteryOptimizationState: BatteryOptimizationState, val showNewNotificationSoundBanner: Boolean, + val showSwipeAvatarBanner: Boolean, val showUnreadCount: Boolean, val summaries: ImmutableList, val seenRoomInvites: ImmutableSet, + val showAllActivity: Boolean, ) : RoomListContentState } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt index 1e051bd807..ef4d031540 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchEvent.kt @@ -29,5 +29,11 @@ sealed interface GlobalSearchEvent { data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent /** The user selected a search result from the search history. */ - data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent + data class SearchHistoryResultSelected(val resultItem: SearchHistoryListItem) : GlobalSearchEvent + + /** The user removed a search history entry. */ + data class RemoveSearchHistoryResult(val result: SearchHistoryListItem) : GlobalSearchEvent + + /** The user cleared the search history. */ + data object ClearSearchHistory : GlobalSearchEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index 7b8b21be38..bbd7d57ae3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -10,12 +10,14 @@ package io.element.android.features.home.impl.search import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.home.impl.search.history.SearchHistoryResult @@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.search.MessageSearch import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState import io.element.android.libraries.matrix.api.search.MessageSearchResult import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType @@ -57,6 +60,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -80,6 +84,7 @@ class GlobalSearchPresenter( val isEnabled by produceState(false) { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it } } + val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) } val queryState = rememberTextFieldState() var isSearchActive by remember { mutableStateOf(false) } @@ -87,14 +92,16 @@ class GlobalSearchPresenter( var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) } val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) } - val searchHistory by produceState>>(initialValue = AsyncData.Uninitialized) { + StartSearchBackfillWhenVisible(isEnabled = isEnabled, isSearchActive = isSearchActive) + + val searchHistory by produceState>>(initialValue = AsyncData.Uninitialized) { searchHistoryStore.history.collectLatest { history -> val mappedHistoryItems = history.mapNotNull { result -> when (result) { - is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term) + is SearchHistoryResult.Query -> SearchHistoryListItem.Query(term = result.term) is SearchHistoryResult.Room -> { val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null - SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo) + SearchHistoryListItem.Room(roomId = result.roomId, roomInfo = roomInfo) } } } @@ -236,7 +243,7 @@ class GlobalSearchPresenter( } is GlobalSearchEvent.SearchHistoryResultSelected -> { when (event.resultItem) { - is SearchHistoryResultItem.Query -> coroutineScope.launch { + is SearchHistoryListItem.Query -> coroutineScope.launch { delay(300.milliseconds) queryState.edit { replace(0, length, event.resultItem.term) @@ -244,9 +251,21 @@ class GlobalSearchPresenter( searchResults = AsyncData.Loading() currentTarget = GlobalSearchTarget.ROOMS } - is SearchHistoryResultItem.Room -> Unit + is SearchHistoryListItem.Room -> Unit } } + is GlobalSearchEvent.RemoveSearchHistoryResult -> coroutineScope.launch { + val currentHistory = searchHistoryStore.history.first() + val id = when (event.result) { + is SearchHistoryListItem.Query -> event.result.term + is SearchHistoryListItem.Room -> event.result.roomId.value + } + val result = currentHistory.find { it.id == id } ?: return@launch + searchHistoryStore.remove(result) + } + GlobalSearchEvent.ClearSearchHistory -> coroutineScope.launch { + searchHistoryStore.clear() + } } } @@ -265,7 +284,7 @@ class GlobalSearchPresenter( result: MessageSearchResult, roomInfo: RoomInfo, formattedTimestamp: String, - ): MessageSearchResultItem.Message? { + ): MessageSearchListItem.Message? { val body = latestEventFormatter.format( latestEvent = LatestEventValue.Remote( timestamp = result.timestamp, @@ -276,7 +295,7 @@ class GlobalSearchPresenter( ), isDmRoom = false, ) - return MessageSearchResultItem.Message( + return MessageSearchListItem.Message( messageSearchResult = result, body = body?.toString() ?: "", formattedTimestamp = formattedTimestamp, @@ -284,11 +303,40 @@ class GlobalSearchPresenter( ) } + @Composable + private fun StartSearchBackfillWhenVisible( + isEnabled: Boolean, + isSearchActive: Boolean, + ) { + val coroutineScope = rememberCoroutineScope() + var hasBackfillRun by rememberSaveable { mutableStateOf(false) } + if (isEnabled && isSearchActive && !hasBackfillRun) { + DisposableEffect(Unit) { + // When we open the message search, we want to backfill the search results so that we can show the user more results if they scroll down. + val handle = matrixClient.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrNull() + ?: return@DisposableEffect onDispose { + Timber.w("Could not start search backfill, it may already be running.") + } + + val job = coroutineScope.launch(coroutineDispatchers.io) { + while (handle.isRunning()) { + delay(100.milliseconds) + } + hasBackfillRun = true + } + onDispose { + job.cancel() + handle.close() + } + } + } + } + private fun mapMediaContent( result: MessageSearchResult, roomInfo: RoomInfo, formattedTimestamp: String, - ): MessageSearchResultItem.Media? { + ): MessageSearchListItem.Media? { val messageType = when (val content = result.content) { is MessageContent if content.type is MessageTypeWithAttachment -> content.type as MessageTypeWithAttachment else -> return null @@ -342,7 +390,7 @@ class GlobalSearchPresenter( blurhash = blurhash ) - return MessageSearchResultItem.Media( + return MessageSearchListItem.Media( messageSearchResult = result, mediaContent = mediaContent, formattedTimestamp = formattedTimestamp, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt index 44b94bd512..d60290b62e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchState.kt @@ -28,20 +28,26 @@ data class GlobalSearchState( val queryState: TextFieldState, val currentTarget: GlobalSearchTarget, val results: AsyncData, - val history: AsyncData>, + val history: AsyncData>, val eventSink: (GlobalSearchEvent) -> Unit ) +/** + * The target of the global search: either rooms or messages. + */ @Immutable enum class GlobalSearchTarget { ROOMS, MESSAGES, } +/** + * The results of a global search, either a list of rooms or a list of messages. + */ @Immutable sealed interface GlobalSearchResults { data class RoomListResults(val results: ImmutableList) : GlobalSearchResults - data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults + data class MessageSearchResults(val results: ImmutableList) : GlobalSearchResults fun isEmpty(): Boolean = when (this) { is RoomListResults -> results.isEmpty() @@ -49,7 +55,10 @@ sealed interface GlobalSearchResults { } } -sealed interface MessageSearchResultItem { +/** + * A single item in the message search results. It's the UI representation of [MessageSearchResult], which is the mapped model returned by the Matrix SDK. + */ +sealed interface MessageSearchListItem { val roomId: RoomId val eventId: EventId val senderId: UserId @@ -57,24 +66,30 @@ sealed interface MessageSearchResultItem { val roomInfo: RoomInfo val formattedTimestamp: String + /** + * A message item in the message search results. + */ data class Message( val messageSearchResult: MessageSearchResult, val body: String, override val roomInfo: RoomInfo, override val formattedTimestamp: String, - ) : MessageSearchResultItem { + ) : MessageSearchListItem { override val roomId: RoomId = messageSearchResult.roomId override val eventId: EventId = messageSearchResult.eventId override val senderId: UserId = messageSearchResult.senderId override val senderName: String = messageSearchResult.senderProfile.getDisambiguatedDisplayName(senderId) } + /** + * A message with media item in the message search results. + */ data class Media( val messageSearchResult: MessageSearchResult, val mediaContent: MediaSearchResultContent, override val roomInfo: RoomInfo, override val formattedTimestamp: String, - ) : MessageSearchResultItem { + ) : MessageSearchListItem { override val roomId: RoomId = messageSearchResult.roomId override val eventId: EventId = messageSearchResult.eventId override val senderId: UserId = messageSearchResult.senderId @@ -82,20 +97,28 @@ sealed interface MessageSearchResultItem { } } -sealed interface SearchHistoryResultItem { +/** + * A single entry in the global search history, displayed in the list of recent searches. + * + * It's the UI representation of [io.element.android.features.home.impl.search.history.SearchHistoryResult], which is the persisted model. + */ +sealed interface SearchHistoryListItem { val id: String - data class Query(val term: String) : SearchHistoryResultItem { + data class Query(val term: String) : SearchHistoryListItem { override val id: String = "query:$term" } data class Room( val roomId: RoomId, val roomInfo: RoomInfo, - ) : SearchHistoryResultItem { + ) : SearchHistoryListItem { override val id: String = "room:${roomId.value}" } } +/** + * Represents the media content of a message search result. + */ data class MediaSearchResultContent( val filename: String, val extension: String?, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt index f9cc105a77..ab1efca019 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchStatePreviewParam.kt @@ -79,13 +79,13 @@ class GlobalSearchStatePreviewParam : PreviewParameterProvider = AsyncData.Uninitialized, - history: AsyncData> = AsyncData.Uninitialized, + history: AsyncData> = AsyncData.Uninitialized, eventSink: (GlobalSearchEvent) -> Unit = {}, ) = GlobalSearchState( isEnabled = isEnabled, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt index cce0620f3f..5154289770 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchView.kt @@ -12,12 +12,14 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.foundation.layout.size import androidx.compose.foundation.layout.width @@ -31,9 +33,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.KeyboardActionHandler import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -67,6 +71,8 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.roundToPx +import io.element.android.libraries.designsystem.text.toDp +import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.FilledTextField import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon @@ -75,6 +81,7 @@ import io.element.android.libraries.designsystem.theme.components.LinearProgress import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.SegmentedButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding @@ -155,10 +162,14 @@ private fun GlobalSearchContent( val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf() when { state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) { - searchHistory(historyResults) { result -> + searchHistory( + historyResults = historyResults, + onRemoveResult = { item -> state.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(item)) }, + onClearAllResults = { state.eventSink(GlobalSearchEvent.ClearSearchHistory) }, + ) { result -> when (result) { - is SearchHistoryResultItem.Query -> Unit - is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null) + is SearchHistoryListItem.Query -> Unit + is SearchHistoryListItem.Room -> onSelectSearchResult(result.roomInfo.id, null) } state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result)) @@ -214,7 +225,7 @@ private fun SearchTopAppBarContent(state: GlobalSearchState) { modifier = Modifier.clip(CircleShape).size(20.dp), onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }, colors = IconButtonDefaults.iconButtonColors( - containerColor = ElementTheme.colors.borderInteractivePrimary, + containerColor = ElementTheme.colors.iconSecondary, contentColor = ElementTheme.colors.iconOnSolidPrimary ) ) { @@ -279,31 +290,52 @@ private fun LazyListScope.startSearching() { } private fun LazyListScope.searchHistory( - historyResults: ImmutableList, - onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit, + historyResults: ImmutableList, + onRemoveResult: (SearchHistoryListItem) -> Unit, + onClearAllResults: () -> Unit, + onSelectSearchHistoryResult: (SearchHistoryListItem) -> Unit, ) { item { - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), - text = stringResource(R.string.screen_search_recent_searches_section_title), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) + Row( + modifier = Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(start = 16.dp).weight(1f), + text = stringResource(R.string.screen_search_recent_searches_section_title), + style = ElementTheme.typography.fontBodyLgMedium, + color = ElementTheme.colors.textPrimary, + ) + + TextButton( + text = stringResource(CommonStrings.action_clear_all), + size = ButtonSize.Small, + colors = ButtonDefaults.textButtonColors(contentColor = ElementTheme.colors.textActionAccent), + onClick = onClearAllResults, + ) + } } items( items = historyResults, key = { it.id }, ) { result -> + val twoLineHeight = + // Vertical padding + 12.dp * 2 + + // Text line heights + ElementTheme.typography.fontBodyMdRegular.lineHeight.toDp() + + ElementTheme.typography.fontBodyLgRegular.lineHeight.toDp() Row( modifier = Modifier - .niceClickable(onClick = { onSelectSearchHistoryResult(result) }) + .clickable(onClick = { onSelectSearchHistoryResult(result) }) .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(start = 16.dp, end = 2.dp) + .height(twoLineHeight), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { when (result) { - is SearchHistoryResultItem.Query -> { + is SearchHistoryListItem.Query -> { Icon( modifier = Modifier.size(32.dp) .clip(RoundedCornerShape(8.dp)) @@ -315,13 +347,14 @@ private fun LazyListScope.searchHistory( ) Text( + modifier = Modifier.weight(1f, fill = true), text = result.term, maxLines = 1, overflow = TextOverflow.Ellipsis, style = ElementTheme.typography.fontBodyLgRegular, ) } - is SearchHistoryResultItem.Room -> { + is SearchHistoryListItem.Room -> { val heroAvatars = remember(result.roomInfo.heroes) { result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList() } @@ -333,7 +366,7 @@ private fun LazyListScope.searchHistory( ), ) - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f, fill = true)) { Text( text = result.roomInfo.name.orEmpty(), maxLines = 1, @@ -365,6 +398,16 @@ private fun LazyListScope.searchHistory( } } } + + Icon( + modifier = Modifier.minimumInteractiveComponentSize() + .niceClickable { + onRemoveResult(result) + }, + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_remove), + tint = ElementTheme.colors.iconSecondary, + ) } } } @@ -451,7 +494,7 @@ private fun LazyListScope.roomListResults( } private fun LazyListScope.messageSearchResults( - results: ImmutableList, + results: ImmutableList, onSearchResultSelected: (RoomId, EventId?) -> Unit, ) { if (results.isNotEmpty()) { @@ -471,7 +514,7 @@ private fun LazyListScope.messageSearchResults( ) { index, result -> val clickableModifier = Modifier.niceClickable(onClick = { onSearchResultSelected(result.roomId, result.eventId) }) when (result) { - is MessageSearchResultItem.Message -> TextMessageSearchResultItemView( + is MessageSearchListItem.Message -> TextMessageSearchResultItemView( modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), avatarType = AvatarType.Room( @@ -482,7 +525,7 @@ private fun LazyListScope.messageSearchResults( body = result.body, formattedTimestamp = result.formattedTimestamp, ) - is MessageSearchResultItem.Media -> { + is MessageSearchListItem.Media -> { MediaMessageSearchResultItemView( modifier = clickableModifier.addSeparatorLine(color = ElementTheme.colors.separatorSecondary, isLastItem = index == results.lastIndex), avatarData = result.roomInfo.getAvatarData(AvatarSize.RoomListItem), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt index ff354cf717..6bc3c94fdd 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchView.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldLineLimits -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -88,7 +87,6 @@ internal fun RoomListSearchView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomListSearchContent( state: RoomListSearchState, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt index 4da3456043..b8ab5a385c 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStore.kt @@ -54,6 +54,17 @@ class DefaultSearchHistoryStore( } } + override suspend fun remove(result: SearchHistoryResult) { + withContext(dispatchers.io) { + mutex.withLock { + ensureLoadedLocked() + val updated = cache.value.filterNot { it == result } + cache.value = updated + persist(updated) + } + } + } + override suspend fun clear() = withContext(dispatchers.io) { mutex.withLock { isLoaded = true diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt index 1b883fa24e..2c37c1038d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryResult.kt @@ -18,9 +18,15 @@ import kotlinx.serialization.Serializable */ @Serializable sealed interface SearchHistoryResult { - @Serializable - data class Query(val term: String) : SearchHistoryResult + val id: String @Serializable - data class Room(val roomId: RoomId) : SearchHistoryResult + data class Query(val term: String) : SearchHistoryResult { + override val id: String = term + } + + @Serializable + data class Room(val roomId: RoomId) : SearchHistoryResult { + override val id: String = roomId.value + } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt index 7718d32f77..f2c41f26f6 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/history/SearchHistoryStore.kt @@ -30,6 +30,11 @@ interface SearchHistoryStore { */ suspend fun add(result: SearchHistoryResult) + /** + * Remove a [result] from the history. + */ + suspend fun remove(result: SearchHistoryResult) + /** * Remove every entry from the history. */ diff --git a/features/home/impl/src/main/res/values-cs/translations.xml b/features/home/impl/src/main/res/values-cs/translations.xml index 0276db1fbe..5577f82ef2 100644 --- a/features/home/impl/src/main/res/values-cs/translations.xml +++ b/features/home/impl/src/main/res/values-cs/translations.xml @@ -14,7 +14,7 @@ "Vaše úložiště klíčů není synchronizováno" "Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen." "Vylepšete si zážitek z volání" - "Všechny chaty" + "Chaty" "Prostory" "Opravdu chcete odmítnout pozvánku do %1$s?" "Odmítnout pozvání" @@ -46,7 +46,7 @@ Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty""Gratulujeme! Nemáte žádné nepřečtené zprávy!" "Žádost o vstup odeslána" - "Všechny chaty" + "Chaty" "Označit jako přečtené" "Označit jako nepřečtené" "Tato místnost byla aktualizována" diff --git a/features/home/impl/src/main/res/values-fr/translations.xml b/features/home/impl/src/main/res/values-fr/translations.xml index 970f6ca03d..4740c840c7 100644 --- a/features/home/impl/src/main/res/values-fr/translations.xml +++ b/features/home/impl/src/main/res/values-fr/translations.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) vous a invité(e)" "Il s’agit d’une opération ponctuelle, merci d’attendre quelques instants." "Configuration de votre compte." + "Ajouter un compte" + "Naviguer vers %1$s pour vous connecter à un autre compte. Vous pourrez ensuite facilement passer de l’un à l’autre." + "Vous pouvez désormais ajouter plusieurs comptes" "Créer une nouvelle discussion ou un nouveau salon" "Supprimer les filtres" "Commencez par envoyer un message à quelqu’un." @@ -36,6 +39,7 @@ En attendant, vous pouvez désélectionner des filtres pour voir vos autres salo "Vous n’avez aucune invitation en attente." "Priorité basse" "Vous n’avez pas encore de salon à priorité basse" + "Mention" "Veuillez désélectionner des filtres pour voir vos discussions" "Vous n’avez pas de discussions pour cette sélection" "Personnes" diff --git a/features/home/impl/src/main/res/values-hr/translations.xml b/features/home/impl/src/main/res/values-hr/translations.xml index 0e699c9b51..7deb2b50b1 100644 --- a/features/home/impl/src/main/res/values-hr/translations.xml +++ b/features/home/impl/src/main/res/values-hr/translations.xml @@ -24,6 +24,9 @@ "Pozvao vas je korisnik %1$s (%2$s)" "Ovaj se postupak izvodi samo jednom, hvala na čekanju." "Postavljanje vašeg računa." + "Dodaj još jedan račun" + "Idite na %1$s da biste se prijavili na drugi račun. Zatim možete jednostavno prelaziti između njih." + "Sada možete dodati više računa" "Stvori novi razgovor ili sobu" "Ukloni filtre" "Započnite tako da nekome pošaljete poruku." diff --git a/features/home/impl/src/main/res/values-hu/translations.xml b/features/home/impl/src/main/res/values-hu/translations.xml index 36f34aff0f..777ac639ba 100644 --- a/features/home/impl/src/main/res/values-hu/translations.xml +++ b/features/home/impl/src/main/res/values-hu/translations.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) meghívta" "Ez egy egyszeri folyamat, köszönjük a türelmét." "A fiók beállítása." + "Fiók hozzáadása" + "Jelentkezzen be egy másik fiókba itt: %1$s . Ezután könnyedén válthat közöttük." + "Mostantól több fiókot is hozzáadhat" "Új beszélgetés vagy szoba létrehozása" "Szűrők törlése" "Kezdje azzal, hogy üzenetet küld valakinek." diff --git a/features/home/impl/src/main/res/values-in/translations.xml b/features/home/impl/src/main/res/values-in/translations.xml index 36512038ba..3b293502b1 100644 --- a/features/home/impl/src/main/res/values-in/translations.xml +++ b/features/home/impl/src/main/res/values-in/translations.xml @@ -14,7 +14,7 @@ "Penyimpanan kunci Anda tidak sinkron" "Untuk memastikan Anda tidak melewatkan panggilan penting, silakan ubah pengaturan Anda untuk memperbolehkan notifikasi layar penuh ketika ponsel Anda terkunci." "Tingkatkan pengalaman panggilan Anda" - "Semua Obrolan" + "Obrolan" "Space" "Apakah Anda yakin ingin menolak undangan untuk bergabung ke %1$s?" "Tolak undangan" @@ -46,7 +46,7 @@ Untuk sementara, Anda dapat membatalkan pilihan saringan untuk melihat percakapa "Selamat! Anda tidak memiliki pesan yang belum dibaca!" "Permintaan untuk bergabung dikirim" - "Semua Obrolan" + "Obrolan" "Tandai sebagai dibaca" "Tandai sebagai belum dibaca" "Ruangan ini telah ditingkatkan" 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 d4f55c3538..239dfedbd7 100644 --- a/features/home/impl/src/main/res/values-pl/translations.xml +++ b/features/home/impl/src/main/res/values-pl/translations.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) zaprosił Cię" "Jest to jednorazowy proces, dziękujemy za czekanie." "Konfigurowanie Twojego konta." + "Dodaj konto" + "Przejdź do %1$s, aby zalogować się na kolejne konto i łatwo się między nimi przełączać." + "Możesz teraz dodawać wiele kont" "Utwórz nową rozmowę lub pokój" "Wyczyść filtry" "Wyślij komuś wiadomość, aby rozpocząć." 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 24be0e88e7..2de9b15dc0 100644 --- a/features/home/impl/src/main/res/values-ro/translations.xml +++ b/features/home/impl/src/main/res/values-ro/translations.xml @@ -3,54 +3,59 @@ "Dezactivați optimizarea bateriei pentru această aplicație, pentru a vă asigura că toate notificările sunt primite." "Dezactivați optimizarea" "Nu primiți notificări?" - "Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar." + "Sunetul pentru notificări a fost actualizat: mai clar, mai rapid și mai puțin perturbator." "Am reîmprospătat sunetele" - "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." + "Conversațiile dvs. sunt salvate automat cu criptare end-to-end. Pentru a restabili acest backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dvs., 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." + "Faceți un backup al conversațiilor" + "Confirmați cheia de recuperare pentru a menține accesul la backup cheilor și la istoricul mesajelor." "Introduceți cheia de recuperare" "Ați uitat cheia de recuperare?" - "Backup-ul nu este sincronizat" - "Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat." - "Îmbunătățiți-vă experiența in timpul unui apel" + "Backupul cheilor nu este sincronizat" + "Pentru a vă asigura că nu pierdeți niciodată un apel important, modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat." + "Îmbunătățiți-vă experiența în timpul unui apel" "Conversații" "Spații" - "Sigur doriți să refuzați alăturarea la %1$s?" + "Sigur doriți să refuzați invitația de a vă alătura la %1$s?" "Refuzați invitația" - "Sigur doriți să refuzați conversațiile cu %1$s?" + "Sigur doriți să refuzați această conversație privată cu %1$s?" "Refuzați conversația" "Nicio invitație" - "%1$s (%2$s) v-a invitat." - "Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru așteptare." - "Contul dumneavoastră se configurează" + "%1$s (%2$s) v-a trimis o invitație." + "Acesta este un proces care se desfășoară o singură dată, vă mulțumim pentru răbdare." + "Contul dvs. se configurează." + "Adăugați un cont" + "Accesați %1$s pentru a vă conecta la un alt cont. Apoi, puteți trece cu ușurință de la unul la altul." + "Acum puteți adăuga mai multe conturi." "Creați o conversație sau o cameră nouă" "Ștergeți filtrele" "Începeți prin a trimite mesaje cuiva." - "Nu există încă discuții." + "Nu există încă nicio conversație." "Favorite" - "Puteți adăuga un chat la preferințele dvs. în setările de chat. -Deocamdată, puteți deselecta filtrele pentru a vedea celelalte chat-uri" - "Încă nu aveți conversații preferate" + "Puteți adăuga o conversație la favorite din setările conversației. +Deocamdată, puteți deselecta filtrele pentru a vedea celelalte conversații" + "Încă nu aveți conversații favorite" "Invitații" "Nu aveți invitații în așteptare." "Prioritate scăzută" - "Nu aveți încă niciun chat cu prioritate scăzută." - "Puteți deselecta filtrele pentru a vedea celelalte chat-uri" - "Nu aveți chat-uri pentru această selecție" + "Nu aveți încă nicio conversație cu prioritate scăzută." + "Mențiune" + "Nu aveți nicio mențiune necitită" + "Puteți deselecta filtrele pentru a vedea celelalte conversații" + "Nu aveți conversații pentru această selecție" "Persoane" "Încă nu aveți DM-uri" "Camere" "Nu sunteți încă în nicio cameră" "Necitite" - "Felicitari! + "Excelent! Nu aveți mesaje necitite!" "Cererea de alăturare a fost trimisă" "Conversații" "Marcați ca citită" "Marcați ca necitită" - "Această cameră a fost modernizată." - "Spațiile dumneavoastră" - "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate." - "Verificați că sunteți dumneavoastră" + "Această cameră a fost upgradată." + "Spațiile dvs." + "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a vă accesa mesajele criptate." + "Verificați-vă identitatea" diff --git a/features/home/impl/src/main/res/values-uk/translations.xml b/features/home/impl/src/main/res/values-uk/translations.xml index a3c68661b6..a56250a09a 100644 --- a/features/home/impl/src/main/res/values-uk/translations.xml +++ b/features/home/impl/src/main/res/values-uk/translations.xml @@ -24,6 +24,9 @@ "%1$s (%2$s) запрошує вас" "Це одноразовий процес, дякую за очікування." "Налаштування облікового запису." + "Додати обліковий запис" + "Перейдіть до %1$s, щоб увійти в інший обліковий запис. Після цього ви зможете легко перемикатися між ними." + "Відтепер ви можете додавати кілька облікових записів" "Створити нову розмову або кімнату" "Очистити фільтри" "Почніть з обміну повідомленнями з кимось." diff --git a/features/home/impl/src/main/res/values-zh-rTW/translations.xml b/features/home/impl/src/main/res/values-zh-rTW/translations.xml index e63b8d4435..af9653f4f5 100644 --- a/features/home/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/home/impl/src/main/res/values-zh-rTW/translations.xml @@ -36,6 +36,8 @@ "您沒有任何擱置中的邀請。" "低優先度" "您尚無任何低優先程度聊天" + "提及" + "您沒有任何未讀提及" "您可以取消選取篩選條件以檢視其他聊天" "您並無此選擇的聊天" "夥伴" diff --git a/features/home/impl/src/main/res/values-zh/translations.xml b/features/home/impl/src/main/res/values-zh/translations.xml index 91d08766ed..da2792d472 100644 --- a/features/home/impl/src/main/res/values-zh/translations.xml +++ b/features/home/impl/src/main/res/values-zh/translations.xml @@ -24,6 +24,9 @@ "%1$s(%2$s)邀请了你" "此为一次性流程,感谢等待。" "设置账户。" + "添加账户" + "转到 %1$s 以登录另一账户。之后你可以轻松地在它们之间切换。" + "你现在可以添加多个账户" "创建新的对话或房间" "清除筛选条件" "通过向某人发送消息来开始。" diff --git a/features/home/impl/src/main/res/values/localazy.xml b/features/home/impl/src/main/res/values/localazy.xml index b3c14354f5..d1983621ac 100644 --- a/features/home/impl/src/main/res/values/localazy.xml +++ b/features/home/impl/src/main/res/values/localazy.xml @@ -8,6 +8,8 @@ "Your chats are automatically backed up with end-to-end encryption. To restore this backup and retain your digital identity when you lose access to all your devices, you will need your recovery key." "Get recovery key" "Back up your chats" + "Swipe up or down on your avatar to switch between your accounts without going to the app settings." + "Swipe your avatar to switch accounts" "Confirm your recovery key to maintain access to your key storage and message history." "Enter your recovery key" "Forgot your recovery key?" @@ -24,6 +26,9 @@ "%1$s (%2$s) invited you" "This is a one time process, thanks for waiting." "Setting up your account." + "Add account" + "Go to %1$s to sign into another account. Then you can easily switch between them." + "You can now add multiple accounts" "Create a new conversation or room" "Clear filters" "Get started by messaging someone." diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt index 13c9585386..e8d041bca3 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest { ) ) } + + @Test + fun `other accounts use the data from db, including the avatar thumbnail`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + userDisplayName = "Carol", + userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result[0]).isEqualTo( + MatrixUser( + userId = A_USER_ID_2, + displayName = "Carol", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) + } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index c0cb5f7c01..6e962f6fe0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.services.analytics.test.FakeAnalyticsService @@ -36,7 +37,6 @@ class DefaultHomeEntryPointTest : RobolectricTest() { inviteFriendsUseCase = { lambdaError() }, analyticsService = FakeAnalyticsService(), acceptDeclineInviteView = { _, _, _, _ -> lambdaError() }, - directLogoutView = { _ -> lambdaError() }, reportRoomEntryPoint = { _, _, _ -> lambdaError() }, declineInviteAndBlockUserEntryPoint = FakeDeclineInviteAndBlockEntryPoint(), changeRoomMemberRolesEntryPoint = { _, _, _, _ -> lambdaError() }, @@ -53,6 +53,8 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToEnterRecoveryKey() = lambdaError() override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() + override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 371a718523..93ea9576b8 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -6,16 +6,23 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat +import io.element.android.features.announcement.api.Announcement +import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.home.impl.roomlist.aRoomListState import io.element.android.features.home.impl.spaces.HomeSpacesState import io.element.android.features.home.impl.spaces.aHomeSpacesState -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter 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 import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.test.FakeIndicatorService import io.element.android.libraries.matrix.api.MatrixClient @@ -31,11 +38,19 @@ 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.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.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds class HomePresenterTest { @get:Rule @@ -81,7 +96,7 @@ class HomePresenterTest { val presenter = createHomePresenter( rageshakeFeatureAvailability = { flowOf(true) }, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -98,7 +113,7 @@ class HomePresenterTest { val presenter = createHomePresenter( indicatorService = indicatorService, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -110,6 +125,46 @@ class HomePresenterTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - total unread count is updated on sync heartbeat`() = runTest { + var unreadCount = 0L + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient( + syncService = syncService, + totalUnreadCountResult = { Result.success(unreadCount) }, + ), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + unreadCount = 5 + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient( + syncService = syncService, + totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }, + ), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + expectNoEvents() + } + } + @Test fun `present - should start with no user and then load user with error`() = runTest { val matrixClient = FakeMatrixClient( @@ -120,7 +175,7 @@ class HomePresenterTest { val presenter = createHomePresenter( client = matrixClient, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -134,7 +189,7 @@ class HomePresenterTest { fun `present - NavigationBar change`() = runTest { val presenter = createHomePresenter( sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -145,9 +200,131 @@ class HomePresenterTest { assertThat(finalState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Spaces) } } + + @Test + fun `present - multi account announcement is shown when announced, feature enabled and a single account`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isTrue() + } + } + + @Test + fun `present - multi account announcement is not shown when the feature is disabled`() = runTest { + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to false), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + } + } + + @Test + fun `present - multi account announcement is not shown and is dismissed when there are several accounts`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = "@alice:server.org"), + aSessionData(sessionId = "@bob:server.org"), + ), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + assertThat(expectMostRecentItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } + + @Test + fun `present - dismiss multi account announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.MultiAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val presenter = createHomePresenter( + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData()), + updateUserProfileResult = { _, _, _, _ -> }, + ), + announcementService = announcementService, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MultiAccount.key to true), + ), + ) + presenter.test { + val state = expectMostRecentItem() + assertThat(state.showMultiAccountAnnouncement).isTrue() + state.eventSink(HomeEvent.DismissMultiAccountAnnouncement) + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().showMultiAccountAnnouncement).isFalse() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.MultiAccount)) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - dismissing the swipe avatar announcement`() = runTest { + val onAnnouncementDismissedResult = lambdaRecorder { } + val presenter = createHomePresenter( + announcementService = FakeAnnouncementService( + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ), + appCoroutineScope = this, + ) + presenter.test { + val state = expectMostRecentItem() + state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement) + runCurrent() + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.SwipeAvatarToSwitchAccount)) + } + } + + @Test + fun `present - total unread count is loaded in the initial state`() = runTest { + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }), + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } } -internal fun createHomePresenter( +internal fun TestScope.createHomePresenter( client: MatrixClient = FakeMatrixClient(), syncService: SyncService = FakeSyncService(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), @@ -155,6 +332,9 @@ internal fun createHomePresenter( indicatorService: IndicatorService = FakeIndicatorService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), + announcementService: AnnouncementService = FakeAnnouncementService(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + appCoroutineScope: CoroutineScope = backgroundScope, ) = HomePresenter( client = client, syncService = syncService, @@ -162,7 +342,9 @@ internal fun createHomePresenter( indicatorService = indicatorService, roomListPresenter = { aRoomListState() }, homeSpacesPresenter = homeSpacesPresenter, - logoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, sessionStore = sessionStore, + announcementService = announcementService, + featureFlagService = featureFlagService, + appCoroutineScope = appCoroutineScope, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/components/RoomSummaryRowUnreadIndicatorCountTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/components/RoomSummaryRowUnreadIndicatorCountTest.kt new file mode 100644 index 0000000000..5b4d918ee9 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/components/RoomSummaryRowUnreadIndicatorCountTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.components + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.home.impl.model.createRoomListRoomSummary +import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import org.junit.Test + +class RoomSummaryRowUnreadIndicatorCountTest { + @Test + fun `unread notifications are used when the room is not muted`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 3, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isEqualTo(3L) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(3L) + } + + @Test + fun `unread notifications only show a dot when showUnreadCount is false`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 3, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L) + } + + @Test + fun `unread messages are used when there are no unread notifications and showAllActivity is true, but always display a dot`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 0, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L) + } + + @Test + fun `unread messages are ignored when showAllActivity is false`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 0, + numberOfUnreadMessages = 5, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull() + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull() + } + + @Test + fun `muted room ignores unread notifications and falls back to unread messages displaying a dot when showAllActivity is true`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 3, + numberOfUnreadMessages = 5, + userDefinedNotificationMode = RoomNotificationMode.MUTE, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = true)).isEqualTo(0L) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = false)).isNull() + } + + @Test + fun `no count when there are no unread notifications nor unread messages`() { + val room = createRoomListRoomSummary( + numberOfUnreadNotifications = 0, + numberOfUnreadMessages = 0, + isMarkedUnread = true, + ) + assertThat(room.unreadIndicatorCount(showUnreadCount = true, showAllActivity = true)).isNull() + assertThat(room.unreadIndicatorCount(showUnreadCount = false, showAllActivity = false)).isNull() + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 5245a164d1..f45e0fa7bf 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -12,6 +12,7 @@ import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.AnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.features.home.impl.FakeDateTimeObserver import io.element.android.features.home.impl.datasource.RoomListDataSource import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory @@ -32,13 +33,14 @@ import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLatestEventFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient @@ -54,6 +56,7 @@ 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_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService @@ -70,6 +73,9 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS import io.element.android.libraries.push.api.battery.aBatteryOptimizationState import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +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.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher @@ -217,6 +223,10 @@ class RoomListPresenterTest { val presenter = createRoomListPresenter(client = client) presenter.test { val initialState = awaitItem() + + // Skip intermediate event for loading the value of the feature flags + skipItems(1) + val summary = createRoomListRoomSummary() initialState.eventSink(RoomListEvent.ShowContextMenu(summary)) @@ -260,6 +270,10 @@ class RoomListPresenterTest { val presenter = createRoomListPresenter(client = client) presenter.test { val initialState = awaitItem() + + // Skip intermediate event for loading the value of the feature flags + skipItems(1) + val summary = createRoomListRoomSummary() initialState.eventSink(RoomListEvent.ShowContextMenu(summary)) @@ -309,6 +323,10 @@ class RoomListPresenterTest { ) presenter.test { val initialState = awaitItem() + + // Skip intermediate event for loading the value of the feature flags + skipItems(1) + eventRecorder.assertEmpty() initialState.eventSink(RoomListEvent.ToggleSearchResults) eventRecorder.assertSingle( @@ -398,6 +416,7 @@ class RoomListPresenterTest { client = matrixClient, ) presenter.test { + skipItems(1) // Skip initial state assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java) } } @@ -616,7 +635,7 @@ class RoomListPresenterTest { ) presenter.test { assertThat(announcementService.announcementsToShowFlow().first()).isEmpty() - skipItems(1) + skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags val state = awaitItem() assertThat(state.contentAsRooms().showNewNotificationSoundBanner).isFalse() announcementService.emitAnnouncementsToShow(listOf(Announcement.NewNotificationSound)) @@ -630,6 +649,93 @@ class RoomListPresenterTest { } } + @Test + fun `present - swipe avatar banner`() = runTest { + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(aRoomSummary())), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)) + ) + val roomListService = FakeRoomListService( + createRoomListLambda = { roomList }, + subscribeToVisibleRoomsLambda = { }, + ) + val matrixClient = FakeMatrixClient( + roomListService = roomListService, + ) + val onAnnouncementDismissedResult = lambdaRecorder { } + val announcementService = FakeAnnouncementService( + initialAnnouncementsToShowFlowValue = listOf(Announcement.SwipeAvatarToSwitchAccount), + onAnnouncementDismissedResult = onAnnouncementDismissedResult, + ) + val sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)), + ) + val presenter = createRoomListPresenter( + client = matrixClient, + announcementService = announcementService, + sessionStore = sessionStore, + ) + presenter.test { + skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags + val state = awaitItem() + // Only one account, the banner is not displayed + assertThat(state.contentAsRooms().showSwipeAvatarBanner).isFalse() + sessionStore.addSession(aSessionData(sessionId = A_SESSION_ID_2.value)) + assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isTrue() + state.eventSink(RoomListEvent.DismissSwipeAvatarBanner) + onAnnouncementDismissedResult.assertions().isCalledOnce() + .with(value(Announcement.SwipeAvatarToSwitchAccount)) + // Simulate service updating the value + announcementService.emitAnnouncementsToShow(emptyList()) + assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isFalse() + } + } + + @Test + fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest { + val roomList = FakeDynamicRoomList( + summaries = MutableStateFlow(listOf(aRoomSummary())), + loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1)), + ) + val matrixClient = FakeMatrixClient( + roomListService = FakeRoomListService(createRoomListLambda = { roomList }), + ) + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + ShowAllActivityInRoomListFeature.key to true, + FeatureFlags.UnreadIndicatorCount.key to true, + ) + ) + val presenter = createRoomListPresenter( + client = matrixClient, + featureFlagService = featureFlagService, + ) + presenter.test { + val enabledState = consumeItemsUntilPredicate { state -> + state.contentState is RoomListContentState.Rooms && + state.contentAsRooms().showAllActivity && + state.contentAsRooms().showUnreadCount + }.last() + assertThat(enabledState.contentAsRooms().showAllActivity).isTrue() + assertThat(enabledState.contentAsRooms().showUnreadCount).isTrue() + + featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, false) + val allActivityDisabledState = consumeItemsUntilPredicate { state -> + state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showAllActivity + }.last() + assertThat(allActivityDisabledState.contentAsRooms().showAllActivity).isFalse() + assertThat(allActivityDisabledState.contentAsRooms().showUnreadCount).isTrue() + + featureFlagService.setFeatureEnabled(FeatureFlags.UnreadIndicatorCount, false) + val unreadCountDisabledState = consumeItemsUntilPredicate { state -> + state.contentState is RoomListContentState.Rooms && !state.contentAsRooms().showUnreadCount + }.last() + assertThat(unreadCountDisabledState.contentAsRooms().showAllActivity).isFalse() + assertThat(unreadCountDisabledState.contentAsRooms().showUnreadCount).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + private fun createTestMarkRoomAsRead( client: MatrixClient, notificationCleaner: NotificationCleaner, @@ -664,6 +770,7 @@ class RoomListPresenterTest { announcementService: AnnouncementService = FakeAnnouncementService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), markRoomAsRead: MarkRoomAsRead? = null, + sessionStore: SessionStore = InMemorySessionStore(), ) = RoomListPresenter( client = client, leaveRoomPresenter = { leaveRoomState }, @@ -696,5 +803,6 @@ class RoomListPresenterTest { announcementService = announcementService, coldStartWatcher = FakeAnalyticsColdStartWatcher(), featureFlagService = featureFlagService, + sessionStore = sessionStore, ) } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index bd18cd903e..61ac7b8dc6 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -19,6 +19,7 @@ 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.home.impl.HomeEvent import io.element.android.features.home.impl.HomeView import io.element.android.features.home.impl.R import io.element.android.features.home.impl.aHomeState @@ -50,9 +51,10 @@ class RoomListViewTest : RobolectricTest() { ) ) + // The banner is not included in the range, only the rooms are eventsRecorder.assertList( listOf( - RoomListEvent.UpdateVisibleRange(0..5), + RoomListEvent.UpdateVisibleRange(0..4), ) ) } @@ -63,13 +65,15 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) @@ -81,18 +85,41 @@ class RoomListViewTest : RobolectricTest() { setRoomListView( state = aRoomListState( contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), - eventSink = eventsRecorder, + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, ) ) - // Remove automatic initial events - eventsRecorder.clear() - val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } + @Test + fun `clicking on close swipe avatar banner emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomListView( + state = aRoomListState( + contentState = aRoomsContentState(showSwipeAvatarBanner = true), + // Ignore the visible range updates, which are also emitted when the banner is shrinking + eventSink = { event -> + if (event !is RoomListEvent.UpdateVisibleRange) { + eventsRecorder(event) + } + }, + ) + ) + + eventsRecorder.assertEmpty() + val close = activity!!.getString(CommonStrings.action_close) + onNodeWithContentDescription(close).performClick() + eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) + } + @Test fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -259,6 +286,23 @@ class RoomListViewTest : RobolectricTest() { ) ) } + + @Test + fun `clicking on add account in the multi-account announcement emits the expected Event and invokes the callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + ensureCalledOnce { callback -> + setRoomListView( + state = aRoomListState(), + showMultiAccountAnnouncement = true, + homeEventSink = eventsRecorder, + onAddAccountClick = callback, + ) + clickOn(R.string.screen_multi_account_announcement_action) + // Wait for the bottom sheet to be hidden + waitForIdle() + } + eventsRecorder.assertSingle(HomeEvent.DismissMultiAccountAnnouncement) + } } private fun AndroidComposeUiTest.setRoomListView( @@ -273,12 +317,21 @@ private fun AndroidComposeUiTest.setRoomListView( onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), + onAddAccountClick: () -> Unit = EnsureNeverCalled(), + showMultiAccountAnnouncement: Boolean = false, + homeEventSink: (HomeEvent) -> Unit = {}, ) { setSafeContent { HomeView( - homeState = aHomeState(roomListState = state), + homeState = aHomeState( + roomListState = state, + showMultiAccountAnnouncement = showMultiAccountAnnouncement, + eventSink = homeEventSink, + ), onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, + onAddAccountClick = onAddAccountClick, + onSwitchToAccount = EnsureNeverCalledWithParam(), onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 6d54c59912..5273e5555b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -33,13 +33,16 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.search.MessageSearchService import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeSdkPendingTask import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.search.FakeMessageSearch import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf @@ -205,7 +208,9 @@ class GlobalSearchPresenterTest { @Test fun `present - message search results are mapped when the message search emits`() = runTest { val messageSearch = FakeMessageSearch() - val matrixClient = FakeMatrixClient().apply { + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }), + ).apply { getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( @@ -224,7 +229,7 @@ class GlobalSearchPresenterTest { val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results assertThat(results).hasSize(1) - assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java) cancelAndIgnoreRemainingEvents() } } @@ -232,7 +237,9 @@ class GlobalSearchPresenterTest { @Test fun `present - message search displays the uninitialized state after removing the query`() = runTest { val messageSearch = FakeMessageSearch() - val matrixClient = FakeMatrixClient().apply { + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }), + ).apply { getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( @@ -251,7 +258,7 @@ class GlobalSearchPresenterTest { val successState = consumeItemsUntilPredicate { it.results is AsyncData.Success }.last() val results = (successState.results.dataOrNull() as GlobalSearchResults.MessageSearchResults).results assertThat(results).hasSize(1) - assertThat(results.first()).isInstanceOf(MessageSearchResultItem.Message::class.java) + assertThat(results.first()).isInstanceOf(MessageSearchListItem.Message::class.java) // Remove the query successState.queryState.edit { replace(0, length, "") } @@ -267,7 +274,9 @@ class GlobalSearchPresenterTest { @Test fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest { val messageSearch = FakeMessageSearch() - val matrixClient = FakeMatrixClient().apply { + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }), + ).apply { getRoomInfoLambda = { Result.success(aRoomInfo()) } } val presenter = createGlobalSearchPresenter( @@ -337,6 +346,71 @@ class GlobalSearchPresenterTest { } } + @Test + fun `present - RemoveSearchHistoryResult removes a query entry`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { + add(SearchHistoryResult.Query("keep")) + add(SearchHistoryResult.Query("drop")) + } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("drop"))) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - RemoveSearchHistoryResult removes a room entry`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { + add(SearchHistoryResult.Query("keep")) + add(SearchHistoryResult.Room(A_ROOM_ID)) + } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo()))) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - RemoveSearchHistoryResult with an unknown id does nothing`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { add(SearchHistoryResult.Query("keep")) } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.RemoveSearchHistoryResult(SearchHistoryListItem.Query("query:unknown"))) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("keep")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - ClearSearchHistory removes every entry`() = runTest { + val searchHistoryStore = FakeSearchHistoryStore().apply { + add(SearchHistoryResult.Query("a")) + add(SearchHistoryResult.Room(A_ROOM_ID)) + } + val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(GlobalSearchEvent.ClearSearchHistory) + testScheduler.advanceUntilIdle() + + assertThat(searchHistoryStore.history.first()).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest { val searchHistoryStore = FakeSearchHistoryStore() @@ -345,7 +419,7 @@ class GlobalSearchPresenterTest { val initialState = awaitItem() skipItems(1) - initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo()))) + initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Room(A_ROOM_ID, aRoomInfo()))) testScheduler.advanceUntilIdle() ensureAllEventsConsumed() @@ -361,12 +435,50 @@ class GlobalSearchPresenterTest { // Skip loading search results state skipItems(1) - initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test"))) + initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryListItem.Query("Test"))) assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test") assertThat(awaitItem().results.isLoading()).isTrue() } } + + @Test + fun `present - will automatically start search backfill and it will be cancelled when the presenter is closed`() = runTest { + var isRunning = false + val closeLambda = lambdaRecorder { isRunning = false } + val matrixClient = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { + isRunning = true + Result.success(FakeSdkPendingTask(isRunningLambda = { isRunning }, closeLambda = closeLambda)) + }, + ), + ) + val presenter = createGlobalSearchPresenter( + matrixClient = matrixClient, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.MessageSearch.key to true), + ), + ) + presenter.test { + // Await the initial state, with the feature enabled + val initialState = consumeItemsUntilPredicate { it.isEnabled }.last() + assertThat(isRunning).isFalse() + + // Opening the search starts the backfill + initialState.eventSink(GlobalSearchEvent.ToggleSearchVisibility) + consumeItemsUntilPredicate { it.isSearchActive } + // The backfill polling loop never ends while the task is running, so don't wait until the scheduler is idle + testScheduler.runCurrent() + assertThat(isRunning).isTrue() + closeLambda.assertions().isNeverCalled() + + // Closing the presenter closes the task + cancelAndIgnoreRemainingEvents() + closeLambda.assertions().isCalledOnce() + assertThat(isRunning).isFalse() + } + } } private fun TestScope.createGlobalSearchPresenter( @@ -377,7 +489,8 @@ private fun TestScope.createGlobalSearchPresenter( dateFormatter: DateFormatter = FakeDateFormatter(), fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(), permalinkParser: PermalinkParser = FakePermalinkParser(), - matrixClient: MatrixClient = FakeMatrixClient(), + matrixClient: MatrixClient = + FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) })), searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(), ): GlobalSearchPresenter { return GlobalSearchPresenter( diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt index f63e21496e..45b3e52acf 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchViewTest.kt @@ -162,7 +162,7 @@ class GlobalSearchViewTest : RobolectricTest() { results = AsyncData.Success( GlobalSearchResults.MessageSearchResults( persistentListOf( - MessageSearchResultItem.Message( + MessageSearchListItem.Message( messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId), body = "A message body", roomInfo = aRoomInfo(id = roomId, name = "A message room"), @@ -184,7 +184,7 @@ class GlobalSearchViewTest : RobolectricTest() { @Test fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest { - val queryResult = SearchHistoryResultItem.Query("Recent term") + val queryResult = SearchHistoryListItem.Query("Recent term") val eventsRecorder = EventsRecorder() setGlobalSearchView( state = aGlobalSearchState( @@ -203,10 +203,47 @@ class GlobalSearchViewTest : RobolectricTest() { eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult)) } + @Test + fun `clicking on the remove button of a history result - emits the expected Event`() = runAndroidComposeUiTest { + val queryResult = SearchHistoryListItem.Query("Recent term") + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success(persistentListOf(queryResult)), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithContentDescription(activity!!.getString(CommonStrings.action_remove)).performClick() + eventsRecorder.assertSingle(GlobalSearchEvent.RemoveSearchHistoryResult(queryResult)) + } + + @Test + fun `clicking on clear all in the history - emits the expected Event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setGlobalSearchView( + state = aGlobalSearchState( + isSearchActive = true, + queryState = TextFieldState(), + results = AsyncData.Uninitialized, + history = AsyncData.Success(persistentListOf(SearchHistoryListItem.Query("Recent term"))), + eventSink = eventsRecorder, + ), + ) + // Remove automatic initial events + eventsRecorder.clear() + onNodeWithText("Clear all").performClick() + eventsRecorder.assertSingle(GlobalSearchEvent.ClearSearchHistory) + } + @Test fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest { val roomId = RoomId("!aHistoryRoom:server.org") - val roomResult = SearchHistoryResultItem.Room( + val roomResult = SearchHistoryListItem.Room( roomId = roomId, roomInfo = aRoomInfo(id = roomId, name = "A history room"), ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt index a0a63755dc..fccd1bda93 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/DefaultSearchHistoryStoreTest.kt @@ -89,6 +89,31 @@ class DefaultSearchHistoryStoreTest { ).inOrder() } + @Test + fun `remove deletes only the given entry and persists the change`() = runTest { + val fileStore = FakeSearchHistoryFileStore() + val store = createStore(fileStore) + store.add(SearchHistoryResult.Query("a")) + store.add(SearchHistoryResult.Room(A_ROOM_ID)) + store.add(SearchHistoryResult.Query("b")) + + store.remove(SearchHistoryResult.Room(A_ROOM_ID)) + + val expected = listOf(SearchHistoryResult.Query("b"), SearchHistoryResult.Query("a")) + assertThat(store.history.first()).containsExactlyElementsIn(expected).inOrder() + assertThat(createStore(fileStore).history.first()).containsExactlyElementsIn(expected).inOrder() + } + + @Test + fun `remove of an unknown entry leaves the history unchanged`() = runTest { + val store = createStore() + store.add(SearchHistoryResult.Query("a")) + + store.remove(SearchHistoryResult.Query("unknown")) + + assertThat(store.history.first()).containsExactly(SearchHistoryResult.Query("a")) + } + @Test fun `clear removes every entry and deletes the persisted content`() = runTest { val fileStore = FakeSearchHistoryFileStore() diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt index 14d6a7ce65..d79f9002c6 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/history/FakeSearchHistoryStore.kt @@ -22,6 +22,10 @@ class FakeSearchHistoryStore : SearchHistoryStore { }.take(MAX_SEARCH_HISTORY_SIZE) } + override suspend fun remove(result: SearchHistoryResult) { + state.value = state.value.filterNot { it == result } + } + override suspend fun clear() { state.value = emptyList() } diff --git a/features/invite/api/build.gradle.kts b/features/invite/api/build.gradle.kts index 084ad1088d..390fc6f3eb 100644 --- a/features/invite/api/build.gradle.kts +++ b/features/invite/api/build.gradle.kts @@ -18,7 +18,4 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) - implementation(projects.libraries.designsystem) - implementation(projects.services.analytics.api) } diff --git a/features/invite/impl/build.gradle.kts b/features/invite/impl/build.gradle.kts index e033f2740c..0ea9923f5a 100644 --- a/features/invite/impl/build.gradle.kts +++ b/features/invite/impl/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) 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 eaace62309..36a90e088e 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager @@ -41,7 +40,6 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DeclineAndBlockView( state: DeclineAndBlockState, diff --git a/features/invite/impl/src/main/res/values-ro/translations.xml b/features/invite/impl/src/main/res/values-ro/translations.xml index 747efbed6d..b75bb2cea2 100644 --- a/features/invite/impl/src/main/res/values-ro/translations.xml +++ b/features/invite/impl/src/main/res/values-ro/translations.xml @@ -2,17 +2,17 @@ "Nu veți vedea niciun mesaj sau invitație de la acest utilizator." "Blocați utilizatorul" - "Raportați această cameră furnizorului contului dumneavoastră" + "Raportați această cameră furnizorului contului dvs." "Descrieți motivul raportării…" "Refuzați și blocați" - "Sigur doriți să refuzați alăturarea la %1$s?" + "Sigur doriți să refuzați invitația de a vă alătura la %1$s?" "Refuzați invitația" - "Sigur doriți să refuzați conversațiile cu %1$s?" + "Sigur doriți să refuzați această conversație privată cu %1$s?" "Refuzați conversația" "Nicio invitație" - "%1$s (%2$s) v-a invitat." + "%1$s (%2$s) v-a trimis o invitație." "Da, refuzați și blocați" - "Sunteți sigur că doriți să refuzați invitația de a vă alătura acestei camere? Acest lucru va împiedica, de asemenea, %1$s să vă contacteze sau să vă invite în camere." + "Sigur doriți să refuzați invitația de a vă alătura acestei camere? Acest lucru va împiedica, de asemenea, %1$s să vă contacteze sau să vă invite în camere." "Refuzați invitația și blocați" "Refuzați și blocați" diff --git a/features/invitepeople/impl/build.gradle.kts b/features/invitepeople/impl/build.gradle.kts index 2ab2fcb4a3..7d1dd2ac59 100644 --- a/features/invitepeople/impl/build.gradle.kts +++ b/features/invitepeople/impl/build.gradle.kts @@ -39,12 +39,10 @@ dependencies { implementation(projects.libraries.testtags) implementation(libs.coil.compose) implementation(projects.services.apperror.api) - implementation(projects.libraries.featureflag.api) api(projects.features.invitepeople.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.services.apperror.test) - testImplementation(projects.libraries.featureflag.test) } diff --git a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt index 4da2ec7cea..79d28547b5 100644 --- a/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt +++ b/features/invitepeople/impl/src/main/kotlin/io/element/android/features/invitepeople/impl/InvitePeopleView.kt @@ -171,7 +171,6 @@ private fun InvitePeopleContentView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun InvitePeopleSearchBar( queryState: TextFieldState, diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts index 71acdad128..c18a4b8a81 100644 --- a/features/joinroom/impl/build.gradle.kts +++ b/features/joinroom/impl/build.gradle.kts @@ -37,12 +37,9 @@ dependencies { implementation(projects.features.invite.api) implementation(projects.features.roomdirectory.api) implementation(projects.services.analytics.api) - implementation(projects.libraries.preferences.api) - implementation(projects.appconfig) testCommonDependencies(libs, true) testImplementation(projects.features.invite.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 663b4e440f..9bab09bdda 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -593,7 +592,6 @@ private fun DefaultLoadedContent( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun JoinRoomTopBar( contentState: ContentState, diff --git a/features/joinroom/impl/src/main/res/values-cs/translations.xml b/features/joinroom/impl/src/main/res/values-cs/translations.xml index 85403339e8..a3f9ce09d0 100644 --- a/features/joinroom/impl/src/main/res/values-cs/translations.xml +++ b/features/joinroom/impl/src/main/res/values-cs/translations.xml @@ -27,7 +27,7 @@ "Náhled této místnosti jsme nemohli zobrazit" "%1$s zatím nepodporuje prostory. Prostory můžete používat na webu." "Prostory zatím nejsou podporovány" - "Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci." + "Klepněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci." "Pro zobrazení historie zpráv musíte být členem této místnosti." "Chcete se připojit k této místnosti?" "Náhled není k dispozici" diff --git a/features/joinroom/impl/src/main/res/values-lt/translations.xml b/features/joinroom/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000000..918ab216bd --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,10 @@ + + + "Jus uždraudė %1$s." + "Prisijungimas nepavyko" + "Pamiršti" + "Norint prisijungti, reikia pakvietimo." + "Norint prisijungti, gali prireikti kvietimo arba būti erdvės nariu." + "„%1$s“ dar nepalaiko erdvių. Galite pasiekti erdvės internete." + "Erdvės dar nepalaikomos" + diff --git a/features/joinroom/impl/src/main/res/values-ro/translations.xml b/features/joinroom/impl/src/main/res/values-ro/translations.xml index 8326ce06bf..428c4b344c 100644 --- a/features/joinroom/impl/src/main/res/values-ro/translations.xml +++ b/features/joinroom/impl/src/main/res/values-ro/translations.xml @@ -5,14 +5,14 @@ "Motiv: %1$s." "Anulați cererea" "Da, anulați" - "Sunteți sigur că doriți să anulați cererea de a vă alătura acestei camere?" - "Anulați cererea de alăturare" + "Sigur doriți să anulați cererea de a vă alătura acestei camere?" + "Anulați cerere de alăturare" "Da, refuzați și blocați" - "Sunteți sigur că doriți să refuzați invitația de a vă alătura acestei camere? Acest lucru va împiedica, de asemenea, %1$s să vă contacteze sau să vă invite în camere." + "Sigur doriți să refuzați invitația de a vă alătura acestei camere? Acest lucru va împiedica, de asemenea, %1$s să vă contacteze sau să vă invite în camere." "Refuzați invitația și blocați" "Refuzați și blocați" - "Alăturarea la cameră a eșuat." - "Această cameră este fie accesibilă numai pe bază de invitație, fie exista restricții de acces la nivel de spațiu." + "Nu v-ați putut alătura camerei" + "Această cameră este fie accesibilă numai pe bază de invitație, fie există restricții de acces la nivel de spațiu." "Uitați această cameră" "Aveți nevoie de o invitație pentru a vă alătura acestei camere." "Invitat de" @@ -21,13 +21,13 @@ "Trimiteți o cerere de alăturare" "Caractere permise %1$d din %2$d" "Mesaj (opțional)" - "Veți primi o invitație de a vă alătura camerei dacă cererea dumneavoastră este acceptată." + "Veți primi o invitație de a vă alătura camerei dacă cererea dvs. este acceptată." "Cererea de alăturare a fost trimisă" "Nu am putut afișa previzualizarea camerei. Este posibil ca acest lucru să se datoreze unor probleme de rețea sau de server." "Nu am putut afișa previzualizarea acestei camere." - "%1$s nu suporta încă spații. Puteți accesa spațiile pe web." + "%1$s nu acceptă încă spații. Puteți accesa spațiile pe web." "Spațiile nu sunt încă suportate" - "Faceți clic pe butonul de mai jos și un administrator de cameră va fi notificat. Veți putea să vă alăturați conversației odată aprobată." + "Atingeți butonul de mai jos și un administrator de cameră va fi notificat. Veți putea să vă alăturați conversației după aprobare." "Trebuie să fiți membru al acestei camere pentru a vizualiza mesajele anterioare." "Doriți să vă alăturați acestei camere?" "Previzualizare indisponibilă" diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts index e6a1a30167..2e60838b41 100644 --- a/features/knockrequests/impl/build.gradle.kts +++ b/features/knockrequests/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) implementation(projects.libraries.designsystem) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt index 52b11bc8c4..2124354600 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -479,7 +478,6 @@ private fun KnockRequestsEmptyList( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun KnockRequestsListTopBar(onBackClick: () -> Unit) { TopAppBar( diff --git a/features/knockrequests/impl/src/main/res/values-ro/translations.xml b/features/knockrequests/impl/src/main/res/values-ro/translations.xml index 06bfbc9674..071bdd2b4b 100644 --- a/features/knockrequests/impl/src/main/res/values-ro/translations.xml +++ b/features/knockrequests/impl/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ - "Da, acceptati tot" - "Sunteți sigur că doriți să acceptați toate cererile de alăturare?" + "Da, acceptați tot" + "Sigur doriți să acceptați toate cererile de alăturare?" "Acceptați toate cererile" "Acceptați tot" "Nu am putut accepta toate cererile. Doriți să încercați din nou?" @@ -11,24 +11,24 @@ "Nu s-a putut accepta cererea" "Se acceptă cererea de alăturare" "Da, refuzați și interziceți" - "Sunteți sigur că doriți să refuzați și să interziceți accesul lui %1$s? Acest utilizator nu va mai putea cere accesul pentru a se alătura acestei camere." + "Sigur doriți să refuzați și să interziceți accesul lui %1$s? Acest utilizator nu va mai putea cere accesul pentru a se alătura acestei camere." "Refuzați și interziceți accesul" - "Se refuză și interzice accesul" + "Se refuză și se interzice accesul" "Da, refuzați" - "Sunteți sigur că doriți să refuzați cererea %1$s de a vă alătura acestei camere?" + "Sigur doriți să refuzați cererea lui %1$s de a se alătura acestei camere?" "Refuzați accesul" "Refuzați și interziceți" "Nu am putut refuza această cerere. Doriți să încercați din nou?" - "Cererea nu a putut fi respinsă" - "Se refuza cererea de alăturare" - "Când cineva va cere să se alăture camerei, veți putea vedea cererea aici." + "Nu s-a putut respinge cererea" + "Se refuză cererea de alăturare" + "Când cineva solicită să se alăture camerei, veți vedea cererea aici." "Nu există cereri de alăturare în așteptare" "Se încarcă cererile de alăturare…" "Cereri de alăturare" "%1$s +%2$d utilizator doresc să se alăture acestei camere" "%1$s +%2$d utilizatori doresc să se alăture acestei camere" - "%1$s +%2$d utilizatori doresc să se alăture acestei camere" + "%1$s +%2$d de utilizatori doresc să se alăture acestei camere" "Vizualizați tot" "Acceptați" diff --git a/features/knockrequests/test/build.gradle.kts b/features/knockrequests/test/build.gradle.kts index dc3407da01..726ea6969f 100644 --- a/features/knockrequests/test/build.gradle.kts +++ b/features/knockrequests/test/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.features.knockrequests.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/leaveroom/api/src/main/res/values-ro/translations.xml b/features/leaveroom/api/src/main/res/values-ro/translations.xml index 8f504148f6..be7fe279e8 100644 --- a/features/leaveroom/api/src/main/res/values-ro/translations.xml +++ b/features/leaveroom/api/src/main/res/values-ro/translations.xml @@ -1,10 +1,10 @@ - "Sunteți sigur că doriți să părăsiți această conversație? Această conversație nu este publică și nu veți putea reveni fără o invitație." - "Sunteți sigur că vreți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părasiți, nimeni nu se va mai putea alătura în viitor, inclusiv dumneavoastra." - "Sunteți sigur că vrei să părăsiți această cameră? Această cameră nu este publică și nu va veti putea alătura din nou fără o invitație." + "Sigur doriți să părăsiți această conversație? Această conversație nu este publică și nu veți putea reveni fără o invitație." + "Sigur doriți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părăsiți, nici dvs., nici alte persoane nu se vor putea alătura în viitor." + "Sigur doriți să părăsiți această cameră? Camera nu este publică și nu veți putea să vă alăturați din nou fără o invitație." "Alegeți proprietari" "Sunteți singurul proprietar al acestei camere. Trebuie să transferați proprietatea către o altă persoană înainte de a părăsi camera." "Transferați proprietatea" - "Sunteți sigur că vreți să părăsiți camera?" + "Sigur doriți să părăsiți camera?" diff --git a/features/licenses/api/build.gradle.kts b/features/licenses/api/build.gradle.kts index fcbf81f210..86070f65f9 100644 --- a/features/licenses/api/build.gradle.kts +++ b/features/licenses/api/build.gradle.kts @@ -15,6 +15,4 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) } diff --git a/features/licenses/impl/build.gradle.kts b/features/licenses/impl/build.gradle.kts index 52f21ac0bd..85023488cd 100644 --- a/features/licenses/impl/build.gradle.kts +++ b/features/licenses/impl/build.gradle.kts @@ -31,5 +31,4 @@ dependencies { testCommonDependencies(libs) testImplementation(libs.coroutines.core) - testImplementation(projects.libraries.matrix.test) } 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 14b195a0b5..da3b7d04cb 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.plus import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -28,7 +27,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DependenciesDetailsView( licenseItem: DependencyLicenseItem, 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 17f82cd14e..76845f434d 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,7 +38,6 @@ import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DependencyLicensesListView( state: DependencyLicensesListState, diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts index 4659a2d2b8..7c86e1d057 100644 --- a/features/linknewdevice/impl/build.gradle.kts +++ b/features/linknewdevice/impl/build.gradle.kts @@ -28,38 +28,24 @@ setupDependencyInjection() dependencies { // TODO Cleanup - implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) - implementation(projects.features.rageshake.api) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) - implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) implementation(projects.libraries.permissions.api) - implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.qrcode) - implementation(projects.libraries.oauth.api) - implementation(projects.libraries.uiUtils) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) implementation(libs.androidx.webkit) - implementation(libs.serialization.json) api(projects.features.linknewdevice.api) testCommonDependencies(libs, true) - testImplementation(projects.features.linknewdevice.test) testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.enterprise.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) } diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt index 935c848e42..c43de805b0 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.desktop import androidx.compose.foundation.background @@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt index b60fd285af..b9f583ec2a 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.number import androidx.compose.foundation.layout.Arrangement @@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt index 4cd22e37d2..cecee623d0 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt @@ -5,8 +5,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.qrcode import androidx.compose.animation.AnimatedContent @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt index b27cce17d9..2dbca3940c 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt @@ -5,12 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.linknewdevice.impl.screens.root import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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 5ba904c7fc..211ab4ae6b 100644 --- a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml +++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml @@ -1,26 +1,26 @@ "Scanați codul QR" - "Deschide %1$s pe un laptop sau un computer desktop" + "Deschideți %1$s pe un laptop sau un computer desktop" "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ă." + "Deschideți %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 dispozitivul dvs. 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ă." "Introduceți numărul afișat pe celălalt dispozitiv" - "Furnizorul dumneavoastră de cont nu acceptă %1$s." + "Furnizorul contului dvs. nu acceptă %1$s." "%1$s nu este acceptat" - "Furnizorul contului dumneavoastră nu acceptă conectarea la un dispozitiv nou cu un cod QR." - "Formatul codului QR nu este acceptat." - "Autentificarea a fost anulată de pe celălalt dispozitiv." - "Cererea de autentificare a fost anulată" - "Autentificarea a expirat. Vă rugăm să încercați din nou." - "Autentificarea nu a fost finalizată la timp" + "Furnizorul contului dvs. nu acceptă conectarea la un dispozitiv nou cu un cod QR." + "Codul QR nu este acceptat." + "Conectarea a fost anulată de pe celălalt dispozitiv." + "Cererea de conectare a fost anulată" + "Conectarea a expirat. Încercați din nou." + "Conectarea nu a fost finalizată la timp" "Deschideți %1$s pe celălalt dispozitiv" "Selectați %1$s" - "“Conectați-vă cu un cod QR”" + "„Conectați-vă cu un cod QR”" "Scanați codul QR afișat aici cu celălalt dispozitiv." "Deschideți %1$s pe celălalt dispozitiv" "Calculator desktop" @@ -29,7 +29,7 @@ "Ce tip de dispozitiv doriți să conectați?" "Încercați din nou și asigurați-vă că ați introdus corect codul de 2 cifre. Dacă numerele tot nu se potrivesc, contactați furnizorul contului." "Numerele nu se potrivesc" - "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." + "Nu a putut fi creată o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." "Și acum?" "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea." "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi." @@ -37,25 +37,25 @@ "Conexiunea nu este sigură" "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." "Introduceți numărul de mai jos pe celălalt dispozitiv" - "Autentificarea a fost anulată de pe celălalt dispozitiv." - "Cererea de autentificare a fost anulată" - "Autentificarea a fost refuzată pe celălalt dispozitiv." - "Autentificarea a fost refuzată" + "Conectarea a fost anulată de pe celălalt dispozitiv." + "Cererea de conectare a fost anulată" + "Conectarea a fost refuzată pe celălalt dispozitiv." + "Conectarea a fost refuzată" "Nu trebuie să faceți nimic altceva." "Celălalt dispozitiv este deja conectat" - "Autentificarea a expirat. Vă rugăm să încercați din nou." - "Autentificarea nu a fost finalizată la timp" - "Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR. + "Conectarea a expirat. Încercați din nou." + "Conectarea nu a fost finalizată la timp" + "Celălalt dispozitiv nu acceptă conectarea la %s cu un cod QR. -Încercați să vă autentificați manual sau să scanați codul QR cu un alt dispozitiv." - "Formatul codului QR nu este acceptat." - "Furnizorul dumneavoastră de cont nu acceptă %1$s." +Încercați să vă conectați manual sau să scanați codul QR cu un alt dispozitiv." + "Codul QR nu este acceptat." + "Furnizorul contului dvs. nu acceptă %1$s." "%1$s nu este acceptat" "Utilizați codul QR afișat pe celălalt dispozitiv." "Încercați din nou" "Cod QR greșit" "Trebuie să acordați permisiunea ca %1$s să folosească camera dispozitivului pentru a continua." "Permiteți accesul la cameră pentru a scana codul QR" - "A apărut o eroare neașteptată. Vă rugăm să încercați din nou." + "A apărut o eroare neașteptată. Încercați din nou." "În așteptarea celuilalt dispozitiv" diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts index 388612f920..39a99e7d67 100644 --- a/features/linknewdevice/test/build.gradle.kts +++ b/features/linknewdevice/test/build.gradle.kts @@ -14,6 +14,5 @@ android { } dependencies { - implementation(projects.features.linknewdevice.api) implementation(projects.tests.testutils) } diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 8830b39a22..811c0d89b7 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { buildConfigFieldStr( name = "MAPTILER_BASE_URL", - value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" + value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL, ) buildConfigFieldStr( name = "MAPTILER_API_KEY", @@ -35,8 +35,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") ?: readLocalProperty("services.maptiler.apikey") - } - ?: "" + }, ) buildConfigFieldStr( name = "MAPTILER_LIGHT_MAP_ID", @@ -45,9 +44,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") ?: readLocalProperty("services.maptiler.lightMapId") - } - // fall back to maptiler's default light map. - ?: "basic-v2" + }, ) buildConfigFieldStr( name = "MAPTILER_DARK_MAP_ID", @@ -56,9 +53,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") ?: readLocalProperty("services.maptiler.darkMapId") - } - // fall back to maptiler's default dark map. - ?: "basic-v2-dark" + }, ) } } @@ -68,12 +63,9 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.wellknown.api) implementation(projects.features.enterprise.api) implementation(libs.coil.compose) - implementation(libs.datetime) testCommonDependencies(libs) } 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 index 752dd2401c..bcaa5ac49c 100644 --- 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 @@ -11,13 +11,21 @@ import androidx.compose.runtime.staticCompositionLocalOf import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** - * Provides a [MapTilerConfig] for the MapLibre maps. + * Provides the [MapTilerConfig] for the MapLibre maps, or null if no map provider is configured. */ -val LocalMapTilerConfig = staticCompositionLocalOf { - MapTilerConfig( - apiKey = BuildConfig.MAPTILER_API_KEY, +val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() } + +/** + * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. + */ +fun buildTimeMapTilerConfig(): MapTilerConfig? { + // BuildConfig fields are null when not provided at build time. + val apiKey: String? = BuildConfig.MAPTILER_API_KEY + if (apiKey.isNullOrEmpty()) return null + return MapTilerConfig( + apiKey = apiKey, lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, - baseUrl = BuildConfig.MAPTILER_BASE_URL + 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 41c58c396f..02cb7bba14 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,10 +8,21 @@ package io.element.android.features.location.api +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.libraries.core.coroutine.mapState +import kotlinx.coroutines.flow.StateFlow + /** - * Tells whether the location features can be used at all, which depends on the build having a map provider configured. + * Tells whether the location features can be used at all, which depends on a map provider being configured, + * either by the homeserver or at build time. */ interface LocationService { + /** + * The [MapTilerConfig] to use to render the maps, or null if no map provider is configured. + * The configuration provided by the homeserver takes precedence over the one defined at build time. + */ + val mapTilerConfig: StateFlow + /** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */ - fun isServiceAvailable(): Boolean + fun isServiceAvailable(): StateFlow = mapTilerConfig.mapState { it != null } } 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 b3149964e4..2e8e8b5a60 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 @@ -50,11 +50,12 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables /** * Shows a static map image downloaded via a third party service's static maps API. * - * Handles 4 distinct cases: + * Handles 5 distinct cases: * 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching * 2. Null location - shows blurred placeholder, no pin, no loading - * 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator - * 4. Success (location != null, loaded) - shows actual map with pin + * 3. Null mapTilerConfig (no map provider configured) - shows blurred placeholder, no pin, no fetching + * 4. Loading (location != null, fetching) - shows blurred placeholder with loading indicator + * 5. Success (location != null, loaded) - shows actual map with pin */ @Composable fun StaticMapView( @@ -84,7 +85,8 @@ fun StaticMapView( ) } // Case 2: Null location - show blurred placeholder, no pin, no loading - location == null -> { + // Case 3: No map provider configured - show blurred placeholder, no pin, no fetching + location == null || mapTilerConfig == null && !LocalInspectionMode.current -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), canReload = false, @@ -94,7 +96,7 @@ fun StaticMapView( onLoadMapClick = {} ) } - // Cases 3 & 4: Non-null location - fetch map + // Cases 4 & 5: Non-null location - fetch map else -> LoadableMapContent( mapTilerConfig = mapTilerConfig, location = location, @@ -150,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent( Triple(painter, state, ContentScale.Crop) } else { val painter = rememberAsyncImagePainter( - model = if (constraints.isZero) { - // Avoid building a URL if any of the size constraints is zero + model = if (builder == null || constraints.isZero) { + // Avoid building a URL if there is no map provider or if any of the size constraints is zero null } else { ImageRequest.Builder(context) @@ -193,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent( else -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), - canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, + canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, height = maxHeight, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt new file mode 100644 index 0000000000..509278b3dd --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.api.internal + +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig + +/** + * Public MapTiler defaults, used when a [MapTilerConfig] does not define a value. + * Values from another configuration (e.g. the build time one) must never be used as a fallback, + * to avoid sending an API key to a server it was not meant for. + */ +internal object MapTilerDefaults { + const val BASE_URL = "https://api.maptiler.com/maps" + const val LIGHT_MAP_ID = "basic-v2" + const val DARK_MAP_ID = "basic-v2-dark" +} + +internal fun MapTilerConfig.baseUrlOrDefault(): String { + return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL +} + +internal fun MapTilerConfig.lightMapIdOrDefault(): String { + return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID +} + +internal fun MapTilerConfig.darkMapIdOrDefault(): String { + return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID +} 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 cd339f67b0..c19d305f3d 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,7 +9,6 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig import kotlin.math.roundToInt /** @@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder( private val lightMapId: String, private val darkMapId: String, ) : StaticMapUrlBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - 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, + baseUrl = mapTilerConfig.baseUrlOrDefault(), + apiKey = mapTilerConfig.apiKey, + lightMapId = mapTilerConfig.lightMapIdOrDefault(), + darkMapId = mapTilerConfig.darkMapIdOrDefault(), ) override fun build( 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 d817a3307f..fc7ddc3fe0 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,33 +11,18 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig - -internal class MapTilerTileServerStyleUriBuilder( - private val baseUrl: String, - private val apiKey: String, - private val lightMapId: String, - private val darkMapId: String, -) : TileServerStyleUriBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) +internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder { override fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String { - val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl - val apiKey = customMapTilerConfig?.apiKey ?: apiKey + val baseUrl = mapTilerConfig.baseUrlOrDefault() val mapId = if (darkMode) { - customMapTilerConfig?.darkStyleId ?: darkMapId + mapTilerConfig.darkMapIdOrDefault() } else { - customMapTilerConfig?.lightStyleId ?: lightMapId + mapTilerConfig.lightMapIdOrDefault() } - - return "$baseUrl/$mapId/style.json?key=$apiKey" + return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.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 31992b1dd6..553a4ed160 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 @@ -36,17 +36,13 @@ interface StaticMapUrlBuilder { density: Float, ): String - /** Whether a map provider is configured for this build; the URLs are unusable when it is not. */ + /** Whether an API key is configured; the URLs are unusable when it is not. */ fun isServiceAvailable(): Boolean } @Composable -fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder { +fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? { return remember(mapTilerConfig) { - if (mapTilerConfig != null) { - MapTilerStaticMapUrlBuilder(mapTilerConfig) - } else { - MapTilerStaticMapUrlBuilder() - } + mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) } } } 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 67936525e5..6195f8fad3 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 @@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder { /** * Builds the style URI the map view loads its tiles from. * - * @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available. + * @param mapTilerConfig the MapTiler configuration to use. * @param darkMode whether to request the dark map style. */ fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String } @@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder { */ @Composable fun rememberTileStyleUrl( - customMapTileConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig?, ): String { val darkMode = !ElementTheme.isLightTheme - return remember(darkMode, customMapTileConfig) { - MapTilerTileServerStyleUriBuilder().build( - customMapTilerConfig = customMapTileConfig, - darkMode = darkMode, - ) + return remember(darkMode, mapTilerConfig) { + // Without config, the map is not displayed, so the style URL is not used. + mapTilerConfig?.let { + MapTilerTileServerStyleUriBuilder().build( + mapTilerConfig = it, + darkMode = darkMode, + ) + }.orEmpty() } } diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt index 3d17107104..c46b7a41d5 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerStaticMapUrlBuilderTest { @@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest { assertThat(builder.isServiceAvailable()).isTrue() } + @Test + fun `builder created from a config only uses the config values`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", + ) + ) + assertThat(configBuilder.isServiceAvailable()).isTrue() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f) + ).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright") + } + + @Test + fun `builder created from a partial config falls back to the public MapTiler defaults`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ) + ) + assertThat(configBuilder.isServiceAvailable()).isFalse() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f) + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright") + } + @Test fun `isServiceAvailable returns false if api key is empty`() { val builderWithoutKey = MapTilerStaticMapUrlBuilder( 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 c153ab1ce4..12c3a7ec63 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 @@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { - private val builder = MapTilerTileServerStyleUriBuilder( - baseUrl = "https://base.url", - apiKey = "anApiKey", - lightMapId = "aLightMapId", - darkMapId = "aDarkMapId", + private val builder = MapTilerTileServerStyleUriBuilder() + + private val config = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", ) @Test fun `light map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = false, ) - ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY") } @Test fun `dark map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = true, ) - ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY") } @Test - fun `custom map uri light`() { + fun `missing values fall back to the public MapTiler defaults`() { + val partialConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = null, + darkStyleId = " ", + baseUrl = " ", + ) assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = false, ) - ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY") - } - - @Test - fun `custom map uri dark`() { + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY") assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = true, ) - ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY") + ).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY") } @Test - fun `blank custom map uri falls back to the built-in style`() { + fun `blank api key is kept as is`() { assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = " " - ), + mapTilerConfig = config.copy(apiKey = ""), darkMode = false, ) - ).isEqualTo("https://base.url/light/style.json?key=API_KEY") + ).isEqualTo("https://custom.url/maps/light/style.json?key=") } } diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 1fcb2e1fc4..9733f3c768 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -42,14 +42,11 @@ dependencies { implementation(projects.services.appnavstate.api) implementation(libs.accompanist.permission) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.dateformatter.api) 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) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) @@ -58,10 +55,8 @@ dependencies { testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) - 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/DefaultLocationService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt index 9f2c3fa70b..aa14e6505b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt @@ -8,14 +8,31 @@ package io.element.android.features.location.impl -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.location.api.BuildConfig +import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.LocationService +import io.element.android.features.location.api.buildTimeMapTilerConfig +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn -@ContributesBinding(AppScope::class) -class DefaultLocationService : LocationService { - override fun isServiceAvailable(): Boolean { - return BuildConfig.MAPTILER_API_KEY.isNotEmpty() - } +@SingleIn(SessionScope::class) +@ContributesBinding(SessionScope::class) +class DefaultLocationService( + customMapTilerConfigProvider: CustomMapTilerConfigProvider, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, +) : LocationService { + override val mapTilerConfig: StateFlow = flow { + customMapTilerConfigProvider.get().getOrNull()?.let { emit(it) } + }.stateIn( + scope = sessionCoroutineScope, + started = SharingStarted.Eagerly, + initialValue = buildTimeMapTilerConfig(), + ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index 60e53b4f2b..993e5c9871 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common import android.Manifest import org.maplibre.compose.camera.CameraPosition -import org.maplibre.compose.map.GestureOptions -import org.maplibre.compose.map.MapOptions -import org.maplibre.compose.map.RenderOptions import org.maplibre.spatialk.geojson.Position /** * Common configuration values for the map. */ object MapDefaults { - val options = MapOptions( - renderOptions = RenderOptions.Standard, - gestureOptions = GestureOptions.Standard, - ) - val defaultCameraPosition = CameraPosition( target = Position(0.0, 0.0), zoom = 0.0, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index e30dfd53de..dd380dca83 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.interaction.ClickResult import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.GeoJsonSource import org.maplibre.compose.sources.rememberGeoJsonSource -import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point 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 983e9fc029..d90888da5c 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 @@ -7,6 +7,7 @@ package io.element.android.features.location.impl.common.ui +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding @@ -41,27 +43,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max -import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.internal.rememberTileStyleUrl -import io.element.android.features.location.impl.common.MapDefaults -import io.element.android.libraries.architecture.AsyncData 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 org.maplibre.compose.camera.CameraState -import org.maplibre.compose.camera.rememberCameraState -import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.interaction.MapInteractions +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.MapUiOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.RenderOptions import org.maplibre.compose.overlay.ExpandingAttributionButton -import org.maplibre.compose.overlay.MapOverlay import org.maplibre.compose.overlay.MaplibreLogo -import org.maplibre.compose.style.BaseStyle -import org.maplibre.compose.util.MaplibreComposable import kotlin.math.roundToInt /** @@ -72,43 +69,45 @@ import kotlin.math.roundToInt * - Updating camera position padding based on sheet height * - Rendering the MaplibreMap with proper ornament positioning * - * @param customMapTilerConfig Optional custom style URL for the map + * @param mapState The map state (camera, style and layer content) for the map. `null` in + * inspection/preview mode, where the native map cannot initialize and a placeholder is shown. + * @param isReady Used to gate rendering until the map should be displayed * @param modifier Modifier for the root layout * @param scaffoldState State for the bottom sheet scaffold - * @param cameraState The camera state for the map - * @param mapOptions The options to configure the map + * @param renderOptions The options to configure the map rendering + * @param interactions The gestures and camera interactions allowed on the map + * @param uiOptions The map UI options (gestures, scrolling, keys, first-frame behaviour) * @param sheetPeekHeight The height of the sheet when collapsed * @param sheetDragHandle Optional drag handle for the sheet * @param sheetSwipeEnabled Whether the sheet can be swiped * @param topBar The top app bar content * @param snackbarHost The snackbar host content * @param sheetContent The content to display in the bottom sheet - * @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) - * @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.) + * @param overlay Content to overlay on top of the map (FAB, pin icons, etc.) */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MapBottomSheetScaffold( - customMapTilerConfig: AsyncData, + mapState: MapState?, + isReady: Boolean, modifier: Modifier = Modifier, scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.PartiallyExpanded, ) ), - cameraState: CameraState = rememberCameraState(), - mapOptions: MapOptions = MapDefaults.options, + renderOptions: RenderOptions = RenderOptions.Standard, + interactions: MapInteractions = MapInteractions.Standard, + uiOptions: MapUiOptions = MapUiOptions.Standard, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, sheetSwipeEnabled: Boolean = true, topBar: (@Composable () -> Unit)? = null, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {}, - mapContent: @Composable @MaplibreComposable () -> Unit = {}, - overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {}, + sheetContent: @Composable ColumnScope.() -> Unit = {}, + overlay: @Composable BoxScope.() -> Unit = {}, ) { val density = LocalDensity.current - val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) @@ -126,7 +125,7 @@ fun MapBottomSheetScaffold( sheetContent = { val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() Column(modifier = Modifier.heightIn(max = maxContentHeight)) { - sheetContent(sheetPadding) + sheetContent() Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } }, @@ -136,39 +135,57 @@ fun MapBottomSheetScaffold( snackbarHost = snackbarHost, topBar = topBar, ) { - Box { - when (customMapTilerConfig) { - is AsyncData.Success -> { + Box(modifier = Modifier.fillMaxSize()) { + when { + isReady && mapState != null -> { MaplibreMap( - options = mapOptions, - cameraPadding = sheetPadding, - contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), - baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), - overlay = MapOverlay { - val style = styleState - Row( - Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + state = mapState, + viewportInsets = sheetPadding, + renderOptions = renderOptions, + interactions = interactions, + uiOptions = uiOptions, + overlay = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(sheetPadding) ) { - MaplibreLogo() - ExpandingAttributionButton(cameraState = cameraState, styleState = style) + Row( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(all = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MaplibreLogo() + ExpandingAttributionButton() + } } }, modifier = Modifier.fillMaxSize(), - cameraState = cameraState, - content = mapContent, ) } + mapState == null -> { + // Inspection/preview mode: the map cannot initialize, render a placeholder + // matching MaplibreMap's own inspection rendering. + Box(modifier = Modifier.fillMaxSize().background(Color.Gray)) + } else -> { Box(modifier = Modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } - overlayContent(sheetPadding) + // App overlay (FABs, pins, live-location indicators). Layered above the map so it is + // present even in inspection/preview mode, where the map is a placeholder. + Box( + modifier = Modifier + .fillMaxSize() + .padding(sheetPadding) + ) { + overlay() + } } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 074404a4cc..bfa94f8c61 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -8,34 +8,28 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.location.Location -import org.maplibre.compose.location.LocationPuck -import org.maplibre.compose.location.LocationPuckColors -import org.maplibre.compose.location.LocationPuckSizes +import org.maplibre.compose.layers.LocationIndicatorDefaults +import org.maplibre.compose.layers.LocationIndicatorLayer +import org.maplibre.compose.location.LocationMeasurement @Composable fun UserLocationPuck( - cameraState: CameraState, - location: Location?, + location: LocationMeasurement?, ) { - LocationPuck( - idPrefix = "user-location", - location = location, - cameraState = cameraState, - accuracyThreshold = Float.POSITIVE_INFINITY, - showBearingAccuracy = false, - showBearing = false, - sizes = LocationPuckSizes( - dotRadius = 8.dp, - dotStrokeWidth = 2.dp, + LocationIndicatorLayer( + id = "user-location", + location = location?.position, + // Hide the accuracy circle and the bearing accuracy sector: leaving `accuracyRadius` + // and `bearingAccuracy` null is what hides them. + accuracyRadius = null, + bearingAccuracy = null, + // Hide the bearing indicator. + bearing = null, + bearingImage = null, + topImage = LocationIndicatorDefaults.topImage( + color = ElementTheme.colors.iconAccentPrimary, + borderColor = ElementTheme.colors.bgCanvasDefault, ), - colors = LocationPuckColors( - dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary, - dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary, - dotStrokeColor = ElementTheme.colors.bgCanvasDefault, - ) ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt index 083cd9dd45..ded16bc79b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt @@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory { .updates(locationRequest) .mapNotNull { event -> when (event) { - is LocationEvent.Fix -> event.location + is LocationEvent.Update -> event.measurement is LocationEvent.Unavailable -> null } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt index 9777e14af5..7b4ed5d914 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt @@ -8,19 +8,18 @@ package io.element.android.features.location.impl.common.userlocation import io.element.android.features.location.api.Location -import org.maplibre.compose.location.PositionWithAccuracy +import org.maplibre.compose.location.LocationMeasurement import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.units.extensions.meters -import kotlin.time.TimeSource -import org.maplibre.compose.location.Location as MapLibreLocation +import kotlin.time.Clock -fun Location.asMapLibreLocation(): MapLibreLocation { - return MapLibreLocation( - position = PositionWithAccuracy( - value = Position(latitude = lat, longitude = lon), - accuracy = accuracy?.toDouble()?.meters +fun Location.toLocationMeasurement(): LocationMeasurement { + return LocationMeasurement( + position = Position( + latitude = lat, + longitude = lon, ), - // Not relevant as not used - timestamp = TimeSource.Monotonic.markNow(), + horizontalAccuracy = accuracy?.toDouble()?.meters, + measuredAt = Clock.System.now(), ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt index 0251f03238..bd300ea82c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt @@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.LocationUnavailableReason -import org.maplibre.compose.location.asMapLibreLocation +import org.maplibre.compose.location.asMapLibreLocationUpdate import org.maplibre.spatialk.units.extensions.inMeters import java.util.ServiceLoader @@ -75,7 +75,7 @@ class PlatformLocationProvider( val locationManager = context.getSystemService(LocationManager::class.java) val listener = object : LocationListenerCompat { override fun onLocationChanged(location: Location) { - trySend(LocationEvent.Fix(location.asMapLibreLocation())) + trySend(location.asMapLibreLocationUpdate()) } override fun onProviderDisabled(provider: String) { @@ -100,7 +100,7 @@ class PlatformLocationProvider( return } locationManager.getLastKnownLocation(provider)?.let { location -> - trySend(LocationEvent.Fix(location.asMapLibreLocation())) + trySend(location.asMapLibreLocationUpdate()) } val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds) .setQuality(request.accuracy.toLocationRequestQuality()) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt index 250ad536b7..938570efd4 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt @@ -9,10 +9,10 @@ package io.element.android.features.location.impl.common.userlocation import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationMeasurement @Immutable -data class UserLocationState(val location: Location?) { +data class UserLocationState(val location: LocationMeasurement?) { fun interface Factory { @Composable fun create(hasLocationPermission: Boolean): UserLocationState diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt index ca6c10b7fa..08cff42c4a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt @@ -10,12 +10,16 @@ package io.element.android.features.location.impl.common.userlocation 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.runtime.snapshotFlow import io.element.android.features.location.impl.common.MapDefaults import kotlinx.coroutines.flow.distinctUntilChanged -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.location.Location +import org.maplibre.compose.camera.CameraAnimation +import org.maplibre.compose.location.LocationMeasurement +import org.maplibre.compose.map.MapState import kotlin.math.abs /** @@ -27,7 +31,7 @@ internal fun UserLocationTrackingEffect( locationState: UserLocationState, enabled: Boolean = true, precision: Double = 0.00001, - onLocationChange: suspend (Location?) -> Unit, + onLocationChange: suspend (LocationMeasurement?) -> Unit, ) { val latestOnLocationChange by rememberUpdatedState(onLocationChange) val latestLocationState by rememberUpdatedState(locationState) @@ -38,8 +42,8 @@ internal fun UserLocationTrackingEffect( .distinctUntilChanged { oldLocation, newLocation -> if (oldLocation != null && newLocation != null) { when { - abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false - abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false + abs(oldLocation.position.latitude - newLocation.position.latitude) >= precision -> false + abs(oldLocation.position.longitude - newLocation.position.longitude) >= precision -> false else -> true } } else { @@ -54,24 +58,30 @@ internal fun UserLocationTrackingEffect( @Composable internal fun UserLocationTrackingEffect( - cameraState: CameraState, + mapState: MapState, locationState: UserLocationState, enabled: Boolean = true, precision: Double = 0.00001, ) { + // Tracks whether the camera has already been placed on the user location once. + var hasPositioned by remember(mapState) { mutableStateOf(false) } UserLocationTrackingEffect( locationState = locationState, enabled = enabled, precision = precision ) { location -> - val target = location?.position?.value ?: cameraState.position.target - cameraState.animateTo( - cameraState.position.copy( - target = target, - // Force pointing to NORTH - bearing = 0.0, - zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) - ) + val target = location?.position ?: return@UserLocationTrackingEffect + val position = mapState.cameraPosition.copy( + target = target, + // Force pointing to NORTH + bearing = 0.0, + zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) ) + if (hasPositioned) { + mapState.animateCamera(position.toCameraUpdate(), CameraAnimation.Fly()) + } else { + mapState.setCameraPosition(position) + hasPositioned = true + } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt index d8868021dc..ff09cca6e8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt @@ -102,11 +102,11 @@ class LiveLocationSharingService : Service() { } .onEach { locationEvent -> when (locationEvent) { - is LocationEvent.Fix -> { + is LocationEvent.Update -> { val location = ApiLocation( - lat = locationEvent.location.position.value.latitude, - lon = locationEvent.location.position.value.longitude, - accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(), + lat = locationEvent.measurement.position.latitude, + lon = locationEvent.measurement.position.longitude, + accuracy = locationEvent.measurement.horizontalAccuracy?.inMeters?.toFloat(), ) coordinator.dispatch(location) } @@ -115,7 +115,6 @@ class LiveLocationSharingService : Service() { LocationUnavailableReason.ServicesDisabled, LocationUnavailableReason.TemporarilyUnavailable -> Unit LocationUnavailableReason.Unsupported, - LocationUnavailableReason.Misconfigured, LocationUnavailableReason.PermissionDenied, LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError() } 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 05af96fe5e..4dbc2fac54 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 @@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -22,7 +21,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.CustomMapTilerConfigProvider +import io.element.android.features.location.api.LocationService 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 @@ -38,7 +37,6 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.messages.api.MessageComposerContext import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.extensions.flatMap @@ -73,7 +71,7 @@ class ShareLocationPresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationStore: LiveLocationStore, private val userLocationStateFactory: UserLocationState.Factory, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, ) : Presenter { @AssistedFactory fun interface Factory { @@ -94,10 +92,7 @@ class ShareLocationPresenter( var pendingLiveLocationShare by remember { mutableStateOf(false) } val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val currentUser by client.userProfile.collectAsState() - val customMapConfig by produceState(AsyncData.Loading()) { - // Ignore errors - value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> perms.sendLiveLocationPermissions() } @@ -201,7 +196,7 @@ class ShareLocationPresenter( } return ShareLocationState( - customMapTilerConfig = customMapConfig, + mapTilerConfig = mapTilerConfig, 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 b29228f866..38747b866f 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 @@ -12,12 +12,11 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( - val customMapTilerConfig: AsyncData, + val mapTilerConfig: MapTilerConfig?, 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/ShareLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt index 908c7f0641..7a192fc4a8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState +import io.element.android.features.location.impl.show.aPreviewMapTilerConfig 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 kotlinx.collections.immutable.persistentListOf @@ -71,13 +71,13 @@ class ShareLocationStatePreviewParam : PreviewParameterProvider = AsyncData.Success(null), + mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, trackUserPosition: Boolean = false, @@ -88,7 +88,7 @@ fun aShareLocationState( eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, 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 4151b5fce3..d19d3fe0f0 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 @@ -13,7 +13,6 @@ package io.element.android.features.location.impl.share import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.itemsIndexed @@ -30,6 +29,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue 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 @@ -37,6 +37,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.Location import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.R import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog @@ -67,11 +68,11 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import org.maplibre.compose.camera.CameraMoveReason -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.rememberMapState +import org.maplibre.compose.style.BaseStyle import kotlin.time.Duration -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShareLocationView( state: ShareLocationState, @@ -104,23 +105,84 @@ fun ShareLocationView( ) } + if (LocalInspectionMode.current) { + // Inspection/preview mode: the native map runtime is unavailable (previews, screenshot + // tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map. + ShareLocationScaffold( + state = state, + mapState = null, + navigateUp = navigateUp, + onSharePinLocation = {}, + modifier = modifier, + ) + } else { + ShareLocationViewContent( + state = state, + navigateUp = navigateUp, + modifier = modifier, + ) + } +} + +@Composable +private fun ShareLocationViewContent( + state: ShareLocationState, + navigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig) + val mapState = rememberMapState( + baseStyle = BaseStyle.Uri(tileStyleUrl), + initialCameraPosition = MapDefaults.defaultCameraPosition, + ) { + UserLocationPuck(location = state.userLocationState.location) + } + UserLocationTrackingEffect( + mapState = mapState, + locationState = state.userLocationState, + enabled = state.trackUserLocation, + ) + LaunchedEffect(mapState.isCameraMoving) { + if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) { + state.eventSink(ShareLocationEvent.StopTrackingUserLocation) + } + } + ShareLocationScaffold( + state = state, + mapState = mapState, + navigateUp = navigateUp, + onSharePinLocation = { + val positionTarget = mapState.cameraPosition.target + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), + isPinned = true + ) + ) + navigateUp() + }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareLocationScaffold( + mapState: MapState?, + state: ShareLocationState, + navigateUp: () -> Unit, + onSharePinLocation: () -> Unit, + modifier: Modifier = Modifier, +) { val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded), ) ) - val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) - - LaunchedEffect(cameraState.isCameraMoving) { - if (cameraState.moveReason == CameraMoveReason.GESTURE) { - state.eventSink(ShareLocationEvent.StopTrackingUserLocation) - } - } - MapBottomSheetScaffold( - customMapTilerConfig = state.customMapTilerConfig, - cameraState = cameraState, + isReady = state.mapTilerConfig != null, + mapState = mapState, modifier = modifier, scaffoldState = scaffoldState, sheetDragHandle = null, @@ -135,38 +197,21 @@ fun ShareLocationView( }, sheetContent = { BottomSheetContent( - cameraState = cameraState, state = state, - navigateUp = navigateUp + navigateUp = navigateUp, + onSharePinLocation = onSharePinLocation, ) }, - mapContent = { - UserLocationTrackingEffect( - cameraState = cameraState, - locationState = state.userLocationState, - enabled = state.trackUserLocation, - ) - UserLocationPuck( - cameraState = cameraState, - location = state.userLocationState.location, - ) - }, - overlayContent = { sheetPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(sheetPadding) - ) { - val variant = if (state.trackUserLocation) { - PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) - } else { - PinVariant.PinnedLocation - } - LocationPin( - variant = variant, - modifier = Modifier.centerBottomEdge(this), - ) + overlay = { + val variant = if (state.trackUserLocation) { + PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) + } else { + PinVariant.PinnedLocation } + LocationPin( + variant = variant, + modifier = Modifier.centerBottomEdge(this), + ) LocationFloatingActionButton( isMapCenteredOnUser = state.trackUserLocation, onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) }, @@ -217,9 +262,9 @@ private fun StartLiveLocationActionView( @Composable private fun BottomSheetContent( - cameraState: CameraState, state: ShareLocationState, navigateUp: () -> Unit, + onSharePinLocation: () -> Unit, ) { Spacer(Modifier.height(20.dp)) val userLocation = state.userLocationState.location @@ -228,8 +273,8 @@ private fun BottomSheetContent( state.eventSink( ShareLocationEvent.ShareStaticLocation( location = Location( - lat = userLocation.position.value.latitude, - lon = userLocation.position.value.longitude + lat = userLocation.position.latitude, + lon = userLocation.position.longitude ), isPinned = false ) @@ -237,18 +282,7 @@ private fun BottomSheetContent( navigateUp() } } else { - SharePinLocationItem( - onClick = { - val positionTarget = cameraState.position.target - state.eventSink( - ShareLocationEvent.ShareStaticLocation( - location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), - isPinned = true - ) - ) - navigateUp() - } - ) + SharePinLocationItem(onClick = onSharePinLocation) } if (state.canShareLiveLocation) { ShareLiveLocationItem { 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 412e96eb9c..0a9911c350 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,8 +22,8 @@ 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.CustomMapTilerConfigProvider import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.isCurrentlySharing @@ -38,8 +38,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.features.location.impl.common.userlocation.asMapLibreLocation -import io.element.android.libraries.architecture.AsyncData +import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta @@ -68,7 +67,7 @@ class ShowLocationPresenter( private val joinedRoom: JoinedRoom, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val userLocationStateFactory: UserLocationState.Factory, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, ) : Presenter { @AssistedFactory fun interface Factory { fun create(mode: ShowLocationMode): ShowLocationPresenter @@ -86,10 +85,7 @@ class ShowLocationPresenter( mutableStateOf(LocationConstraintsDialogState.None) } - val customMapTilerConfig by produceState(AsyncData.Loading()) { - // Ignore errors - value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() fun checkLocationConstraints() { val locationConstraints = checkLocationConstraints( @@ -215,13 +211,13 @@ class ShowLocationPresenter( val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing val userLocationState = if (hideUserLocationPuck) { // When sharing with this device, use the user LocationShareItem as source of data instead of the device. - val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.asMapLibreLocation() } } + val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.toLocationMeasurement() } } UserLocationState(ownLocationShare) } else { userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) } return ShowLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, 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 0472eb94e9..79dacdad1f 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 @@ -13,7 +13,6 @@ import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.libraries.architecture.AsyncData 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 @@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( - val customMapTilerConfig: AsyncData, + val mapTilerConfig: MapTilerConfig?, 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/ShowLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt index 8473eaeb04..4c106c76a5 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt @@ -13,7 +13,6 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE @@ -37,14 +36,21 @@ class ShowLocationStatePreviewParam : PreviewParameterProvider = AsyncData.Success(null), + mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(), isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, locationShares: List = listOf(aLocationShareItem(isLive = isLive)), @@ -56,7 +62,7 @@ fun aShowLocationState( eventSink: (ShowLocationEvent) -> Unit = {}, ): ShowLocationState { return ShowLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, 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 1e4200c7d7..483310a78a 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 @@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.text.style.TextAlign 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.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton @@ -52,12 +54,14 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition -import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.rememberMapState +import org.maplibre.compose.style.BaseStyle import org.maplibre.spatialk.geojson.Position -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShowLocationView( state: ShowLocationState, @@ -73,7 +77,49 @@ fun ShowLocationView( onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, ) - val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) + if (LocalInspectionMode.current) { + // Inspection/preview mode: the native map runtime is unavailable (previews, screenshot + // tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map. + ShowLocationScaffold( + state = state, + mapState = null, + onBackClick = onBackClick, + onLocationShareClick = {}, + modifier = modifier, + ) + } else { + ShowLocationViewContent( + state = state, + onBackClick = onBackClick, + modifier = modifier, + ) + } +} + +@Composable +private fun ShowLocationViewContent( + state: ShowLocationState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig) + val mapState = rememberMapState( + baseStyle = BaseStyle.Uri(tileStyleUrl), + initialCameraPosition = MapDefaults.defaultCameraPosition, + ) { + if (!state.hideUserLocationPuck) { + UserLocationPuck(location = state.userLocationState.location) + } + val markers = remember(state.locationShares) { + state.locationShares.map { it.toMarkerData() }.toImmutableList() + } + LocationPinMarkers(markers) + } + UserLocationTrackingEffect( + mapState = mapState, + locationState = state.userLocationState, + enabled = state.isTrackMyLocation, + ) var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) } LaunchedEffect(state.focusedLocation) { if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) { @@ -82,15 +128,44 @@ fun ShowLocationView( target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) - cameraState.position = position + mapState.setCameraPosition(position) } } - LaunchedEffect(cameraState.isCameraMoving) { - if (cameraState.moveReason == CameraMoveReason.GESTURE) { + LaunchedEffect(mapState.isCameraMoving) { + if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvent.TrackMyLocation(false)) } } + val coroutineScope = rememberCoroutineScope() + ShowLocationScaffold( + state = state, + mapState = mapState, + onBackClick = onBackClick, + onLocationShareClick = { locationShare -> + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) + val position = CameraPosition( + target = Position(locationShare.location.lon, locationShare.location.lat), + // Force pointing to NORTH + bearing = 0.0, + zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM), + ) + coroutineScope.launch { + mapState.animateCamera(update = position.toCameraUpdate(), animation = CameraAnimation.Fly()) + } + }, + modifier = modifier, + ) +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShowLocationScaffold( + state: ShowLocationState, + mapState: MapState?, + onBackClick: () -> Unit, + onLocationShareClick: (LocationShareItem) -> Unit, + modifier: Modifier = Modifier, +) { val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, @@ -103,7 +178,7 @@ fun ShowLocationView( } } MapBottomSheetScaffold( - customMapTilerConfig = state.customMapTilerConfig, + isReady = state.mapTilerConfig != null, sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } } else { @@ -111,7 +186,7 @@ fun ShowLocationView( }, sheetSwipeEnabled = state.isSheetDraggable, scaffoldState = scaffoldState, - cameraState = cameraState, + mapState = mapState, modifier = modifier, topBar = { TopAppBar( @@ -123,8 +198,7 @@ fun ShowLocationView( }, ) }, - sheetContent = { sheetPaddings -> - val coroutineScope = rememberCoroutineScope() + sheetContent = { if (!state.isSheetDraggable) { // If sheet is draggable the DragHandle has already some padding Spacer(Modifier.height(20.dp)) @@ -152,41 +226,13 @@ fun ShowLocationView( item = locationShare, onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) }, - modifier = Modifier.clickable { - state.eventSink(ShowLocationEvent.TrackMyLocation(false)) - val position = CameraPosition( - target = Position(locationShare.location.lon, locationShare.location.lat), - // Force pointing to NORTH - bearing = 0.0, - zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM), - ) - coroutineScope.launch { - cameraState.animateTo(finalPosition = position) - } - } + modifier = Modifier.clickable { onLocationShareClick(locationShare) } ) } } } }, - mapContent = { - UserLocationTrackingEffect( - cameraState = cameraState, - locationState = state.userLocationState, - enabled = state.isTrackMyLocation, - ) - if (!state.hideUserLocationPuck) { - UserLocationPuck( - cameraState = cameraState, - location = state.userLocationState.location, - ) - } - val markers = remember(state.locationShares) { - state.locationShares.map { it.toMarkerData() }.toImmutableList() - } - LocationPinMarkers(markers) - }, - overlayContent = { + overlay = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) }, diff --git a/features/location/impl/src/main/res/values-el/translations.xml b/features/location/impl/src/main/res/values-el/translations.xml index 8ae7c58c74..8dedb9a1eb 100644 --- a/features/location/impl/src/main/res/values-el/translations.xml +++ b/features/location/impl/src/main/res/values-el/translations.xml @@ -2,4 +2,5 @@ "Το ιστορικό ζωντανής τοποθεσίας σας θα αποθηκευτεί στην αίθουσα και θα είναι ορατό στα μέλη μετά το τέλος της συνεδρίας." "Επιλέξτε για πόσο χρονικό διάστημα θα κοινοποιείτε την τρέχουσα τοποθεσία σας." + "Δεν έχετε δικαιώματα για να μοιραστείτε την τρέχουσα τοποθεσία σας σε αυτήν την αίθουσα" diff --git a/features/location/impl/src/main/res/values-ro/translations.xml b/features/location/impl/src/main/res/values-ro/translations.xml index 85e665647a..e01169aa33 100644 --- a/features/location/impl/src/main/res/values-ro/translations.xml +++ b/features/location/impl/src/main/res/values-ro/translations.xml @@ -1,6 +1,6 @@ - "Istoricul locațiilor dumneavoastră va fi stocat în cameră și va fi vizibil pentru membri după încheierea sesiunii." - "Alegeți cât timp doriți să vă partajați locația în timp real." - "Nu aveți permisiunea de a vă partaja locația în această cameră." + "Istoricul locațiilor dvs. în timp real va fi stocat în cameră și va fi vizibil pentru membri după încheierea sesiunii." + "Alegeți pentru cât timp doriți să vă partajați locația în timp real." + "Nu aveți permisiunea de a vă partaja locația în timp real în această cameră." diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt index b0e899730b..8a81895e1f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -9,15 +9,55 @@ package io.element.android.features.location.impl import com.google.common.truth.Truth.assertThat -import io.element.android.features.location.api.BuildConfig +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig +import io.element.android.features.location.api.buildTimeMapTilerConfig +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class DefaultLocationServiceTest { @Test - fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() { - val locationService = DefaultLocationService() - assertThat(locationService.isServiceAvailable()).isEqualTo( - BuildConfig.MAPTILER_API_KEY.isNotEmpty() + fun `when the homeserver does not provide a config, the build time config is used`() = runTest { + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.success(null) }, ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } + + @Test + fun `when the homeserver config cannot be retrieved, the build time config is used`() = runTest { + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.failure(AN_EXCEPTION) }, + ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) + } + + @Test + fun `when the homeserver provides a config, it is used and the service is available`() = runTest { + val config = aMapTilerConfig(apiKey = "homeserver_api_key") + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.success(config) }, + ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(config) + assertThat(locationService.isServiceAvailable().value).isTrue() + } + + private fun TestScope.createDefaultLocationService( + customMapTilerConfigProvider: CustomMapTilerConfigProvider, + sessionCoroutineScope: CoroutineScope = backgroundScope, + ) = DefaultLocationService( + customMapTilerConfigProvider = customMapTilerConfigProvider, + sessionCoroutineScope = sessionCoroutineScope, + ) } 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 a37011e1d9..e57fafa61b 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 @@ -16,6 +16,7 @@ import io.element.android.features.location.impl.common.actions.FakeLocationActi import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.timeline.Timeline @@ -58,7 +59,7 @@ class DefaultShareLocationEntryPointTest { sessionId = room.sessionId, ), userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = { Result.success(null) }, + locationService = FakeLocationService(), ) }, 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 129ea1e4cd..3dcb358cf1 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,9 +19,9 @@ 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.MapTilerConfig import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.actions.FakeLocationActions @@ -31,6 +31,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.core.EventId @@ -89,7 +90,7 @@ class ShareLocationPresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), client: FakeMatrixClient = fakeMatrixClient, - customMapTilerConfigProvider: () -> Result = { Result.success(null) }, + locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()), ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -103,7 +104,7 @@ class ShareLocationPresenterTest { liveLocationShareManager = liveLocationShareManager, liveLocationStore = liveLocationStore, userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = customMapTilerConfigProvider, + locationService = locationService, ) @Test @@ -118,26 +119,24 @@ class ShareLocationPresenterTest { val shareLocationPresenter = createShareLocationPresenter() shareLocationPresenter.test { val state = awaitFirstItem() - assertThat(state.customMapTilerConfig.isLoading()).isFalse() + assertThat(state.mapTilerConfig).isNotNull() assertThat(state.trackUserLocation).isTrue() assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @Test - fun `present - non-null customMapStyleUrl`() = runTest { + fun `present - mapTilerConfig is provided by the location service`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") val shareLocationPresenter = createShareLocationPresenter( client = FakeMatrixClient( sessionId = A_USER_ID, ), - customMapTilerConfigProvider = { Result.success(mapTilerConfig) } + locationService = FakeLocationService(mapTilerConfig = mapTilerConfig), ) shareLocationPresenter.test { - val state = awaitItem() - assertThat(state.customMapTilerConfig.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) + assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig) + cancelAndIgnoreRemainingEvents() } } 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 db5c6b9f9f..83172d1079 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 @@ -18,6 +18,7 @@ import io.element.android.features.location.impl.common.FakeUserLocationStateFac import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta @@ -51,7 +52,7 @@ class DefaultShowLocationEntryPointTest { joinedRoom = joinedRoom, liveLocationShareManager = FakeActiveLiveLocationShareManager(), userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = { Result.success(null) }, + locationService = FakeLocationService(), ) }, 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 1fab0d98af..ab1a1af734 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,9 +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.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory @@ -24,6 +24,7 @@ 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.location.test.FakeLocationService import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom @@ -66,7 +67,7 @@ class ShowLocationPresenterTest { locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), - customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) }, + locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -77,18 +78,16 @@ class ShowLocationPresenterTest { joinedRoom = joinedRoom, liveLocationShareManager = liveLocationShareManager, userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = customMapTilerConfigProvider, + locationService = locationService, ) @Test - fun `present - non-null customMapStyleUrl`() = runTest { + fun `present - mapTilerConfig is provided by the location service`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") - val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) }) - shareLocationPresenter.test { - val state = awaitItem() - assertThat(state.customMapTilerConfig.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) + val showLocationPresenter = createShowLocationPresenter(locationService = FakeLocationService(mapTilerConfig = mapTilerConfig)) + showLocationPresenter.test { + assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig) + cancelAndIgnoreRemainingEvents() } } @@ -120,7 +119,7 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) }) + val presenter = createShowLocationPresenter() presenter.test { val initialState = awaitItem() @@ -407,9 +406,6 @@ 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) @@ -510,8 +506,8 @@ class ShowLocationPresenterTest { val state = awaitItem() assertThat(state.hideUserLocationPuck).isTrue() val location = requireNotNull(state.userLocationState.location) - assertThat(location.position.value.latitude).isEqualTo(ownLocation?.lat) - assertThat(location.position.value.longitude).isEqualTo(ownLocation?.lon) + assertThat(location.position.latitude).isEqualTo(ownLocation?.lat) + assertThat(location.position.longitude).isEqualTo(ownLocation?.lon) } } } diff --git a/features/location/test/build.gradle.kts b/features/location/test/build.gradle.kts index e51737d40c..e08b702f46 100644 --- a/features/location/test/build.gradle.kts +++ b/features/location/test/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { api(projects.features.location.api) + implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt index f961a81c9b..0ff1545131 100644 --- a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt @@ -8,10 +8,21 @@ package io.element.android.features.location.test +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.LocationService +import kotlinx.coroutines.flow.MutableStateFlow class FakeLocationService( - private val isServiceAvailable: Boolean = false, + mapTilerConfig: MapTilerConfig? = null, ) : LocationService { - override fun isServiceAvailable() = isServiceAvailable + constructor(isServiceAvailable: Boolean) : this( + MapTilerConfig( + apiKey = "test_api_key", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ).takeIf { isServiceAvailable } + ) + + override val mapTilerConfig = MutableStateFlow(mapTilerConfig) } diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts index 414b4b5909..d287cbea78 100644 --- a/features/lockscreen/impl/build.gradle.kts +++ b/features/lockscreen/impl/build.gradle.kts @@ -32,12 +32,10 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.cryptography.api) implementation(projects.libraries.preferences.api) - implementation(projects.libraries.testtags) implementation(projects.libraries.uiUtils) implementation(projects.features.logout.api) implementation(projects.libraries.uiCommon) @@ -52,7 +50,6 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.cryptography.test) testImplementation(projects.libraries.cryptography.impl) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.features.logout.test) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt index 7b0f86e386..96ce6d8e2d 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.lockscreen.impl.setup.pin import androidx.compose.foundation.layout.Column @@ -17,7 +15,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable diff --git a/features/lockscreen/impl/src/main/res/values-lt/translations.xml b/features/lockscreen/impl/src/main/res/values-lt/translations.xml index d966db985b..33eda6cdb1 100644 --- a/features/lockscreen/impl/src/main/res/values-lt/translations.xml +++ b/features/lockscreen/impl/src/main/res/values-lt/translations.xml @@ -1,4 +1,7 @@ + "Užrakinkite „%1$s“, kad pridėtumėte papildomą saugumą pokalbiams. + +Pasirinkite ką nors įsimintino. Jei pamiršite šį PIN, būsite atjungti nuo programos." "Šalinamas įrenginys…" diff --git a/features/lockscreen/impl/src/main/res/values-ro/translations.xml b/features/lockscreen/impl/src/main/res/values-ro/translations.xml index 7555d7eb58..88f28413d9 100644 --- a/features/lockscreen/impl/src/main/res/values-ro/translations.xml +++ b/features/lockscreen/impl/src/main/res/values-ro/translations.xml @@ -2,39 +2,39 @@ "autentificare biometrică" "deblocare biometrică" - "Deblocați cu biometrice" + "Deblocați cu datele biometrice" "Confirmați datele biometrice" "Ați uitat codul PIN?" "Schimbați codul PIN" - "Permite deblocarea biometrică" + "Permiteți deblocarea biometrică" "Ștergeți codul PIN" - "Sunteți sigur că doriți să ștergeți codul PIN?" + "Sigur doriți să ștergeți codul PIN?" "Ștergeți codul PIN?" "Permiteți %1$s" "Prefer să folosesc un cod PIN" "Economisiți timp și utilizați %1$s pentru a debloca aplicația de fiecare dată." "Alegeți codul PIN" - "Confirmare PIN" - "Blocați %1$s pentru a adăuga un plus de securitate la conversațiile dvs. + "Confirmați PIN" + "Blocați %1$s pentru un plus de securitate în conversațiile dvs. -Alegeți ceva memorabil. Dacă uitați acest PIN, veți fi deconectat din aplicație." +Alegeți ceva memorabil. Dacă uitați acest PIN, vă veți deconecta de la aplicație." "Nu puteți alege acest cod PIN din motive de securitate" "Alegeți un alt cod PIN" - "Vă rugăm să introduceți același cod PIN de două ori" + "Introduceți același cod PIN de două ori" "Codurile PIN nu corespund" "Va trebui să vă reconectați și să creați un cod PIN nou pentru a continua" - "Acest device este în curs de eliminare" + "Acest dispozitiv este în curs de eliminare" "Aveți %1$d încercare de deblocare" "Aveți %1$d încercări de deblocare" - "Aveți %1$d încercări de deblocare" + "Aveți %1$d de încercări de deblocare" - "PIN greșit. Mai aveți %1$d sansa" - "PIN greșit. Mai aveți %1$d sanse" - "PIN greșit. Mai aveți %1$d sanse" + "PIN greșit. Mai aveți %1$d șansă" + "PIN greșit. Mai aveți %1$d șanse" + "PIN greșit. Mai aveți %1$d de șanse" - "Utilizați biometrice" + "Utilizați datele biometrice" "Utilizați codul PIN" - "Deconectare în curs…" + "Se elimină dispozitivul…" diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt index fb7d3aa372..0b37498a8d 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadTest.kt @@ -12,7 +12,10 @@ package io.element.android.features.lockscreen.impl.unlock.keypad import android.view.KeyEvent import androidx.activity.ComponentActivity +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.hasContentDescription @@ -52,7 +55,9 @@ class PinKeypadTest : RobolectricTest() { @Test fun `typing using the hardware keyboard emits the expected events`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() - setPinKeyPad(onClick = eventsRecorder) + val inputModeManager = setPinKeyPad(onClick = eventsRecorder) + // Like a hardware keyboard would do, leave the touch mode, else the keys are not focusable. + runOnIdle { inputModeManager.requestInputMode(InputMode.Keyboard) } onNodeWithText("1").requestFocus() onAllNodes(isRoot())[0].performKeyInput { val keys = listOf( @@ -115,13 +120,16 @@ class PinKeypadTest : RobolectricTest() { private fun AndroidComposeUiTest.setPinKeyPad( onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(), - ) { + ): InputModeManager { + lateinit var inputModeManager: InputModeManager setContent { + inputModeManager = LocalInputModeManager.current PinKeypad( onClick = onClick, maxWidth = 1000.dp, maxHeight = 1000.dp, ) } + return inputModeManager } } diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 6cf8945e05..8cbfa9575d 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -71,14 +71,11 @@ dependencies { implementation(projects.libraries.oauth.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiUtils) - implementation(projects.libraries.wellknown.api) implementation(libs.androidx.browser) implementation(libs.androidx.webkit) - implementation(libs.serialization.json) api(projects.features.login.api) testCommonDependencies(libs, true) - testImplementation(projects.features.login.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.preferences.test) testImplementation(projects.libraries.preferences.test) @@ -86,7 +83,6 @@ dependencies { testImplementation(projects.libraries.oauth.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.libraries.wellknown.test) testImplementation(libs.androidx.camera.camera2) testImplementation(libs.androidx.camera.lifecycle) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt index dfddd1d496..64158f0e41 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/classic/ElementClassicConnection.kt @@ -25,6 +25,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.features.login.impl.BuildConfig import io.element.android.libraries.androidutils.service.ServiceBinder +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.di.annotations.AppCoroutineScope @@ -69,6 +70,7 @@ class DefaultElementClassicConnection( private val coroutineScope: CoroutineScope, private val matrixAuthenticationService: MatrixAuthenticationService, private val homeServerLoginCompatibilityChecker: HomeServerLoginCompatibilityChecker, + private val dispatchers: CoroutineDispatchers, ) : ElementClassicConnection { // Messenger for communicating with the service. private var messenger: Messenger? = null @@ -221,7 +223,7 @@ class DefaultElementClassicConnection( fun onSessionReceived(data: Bundle) { // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied val state = data.toElementClassicConnectionState() - coroutineScope.launch { + coroutineScope.launch(dispatchers.io) { val updatedState = ensureHomeserverIsSupported(state) emitState(updatedState) val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId 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 c2d0bd05b2..6b86e95ea6 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 @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.login.impl.screens.chooseaccountprovider import androidx.compose.foundation.layout.Box @@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue 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 845c8ec8ee..00012fa03a 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 @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -56,7 +55,6 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginWithClassicView( state: LoginWithClassicState, 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 e1a2d85412..e60358c7f5 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 @@ -56,6 +56,7 @@ class ConfirmAccountProviderNode( ConfirmAccountProviderView( state = state, modifier = modifier, + onBackClick = ::navigateUp, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt index be85a306ba..4657f4c1d7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt @@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider Unit, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, @@ -118,9 +122,15 @@ fun ConfirmAccountProviderView( // Let the header and content scroll so the supporting text is not clipped by the footer when the // keyboard is visible and vertical space is reduced. isScrollable = true, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, header = { IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 60.dp), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { @@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview( ) = ElementPreview { ConfirmAccountProviderView( state = state, + onBackClick = {}, onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index c2a983a692..06e01f1d9d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -70,7 +69,6 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginPasswordView( state: LoginPasswordState, 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 018d55ab09..59d3863b4f 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 @@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold( ) } +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607 + */ @Composable private fun AddOtherAccountScaffold( loginView: @Composable () -> Unit, @@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold( ) { FlowStepPage( modifier = modifier, - title = stringResource(CommonStrings.common_add_account), - iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + title = stringResource(R.string.screen_onboarding_add_account_title), + subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()), buttons = { buttons() }, content = loginView, onBackClick = onBackClick, diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml index 3181e823bb..020ccd04d1 100644 --- a/features/login/impl/src/main/res/values-cs/translations.xml +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -16,7 +16,7 @@ "Google Play" "Na %1$s je vyžadována aplikace Element Pro. Stáhněte si ji prosím z obchodu." "Vyžadován Element Pro" - "Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc." + "Nepodařilo se nám spojit s tímto poskytovatelem účtu. Zkontrolujte prosím, zda jste správně zadali URL adresu poskytovatele účtu. Pokud je URL adresa správná, obraťte se na administrátora poskytovatele účtu, který vám poskytne další pomoc." "Server není k dispozici kvůli problému se souborem well-known: %1$s" "Vybraný poskytovatel účtu nepodporuje klouzavou synchronizaci. Pro použití %1$s je nutná aktualizace serveru." @@ -32,7 +32,7 @@ "Nesprávné uživatelské jméno nebo heslo" "Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'" "Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle." - "Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OAuth. Kontaktujte prosím svého správce nebo vyberte jiný domovský server." + "Vybraný poskytovatel účtu nepodporuje přihlášení pomocí hesla ani OAuth. Kontaktujte prosím svého poskytovatele účtu nebo zvolte jiného." "Zadejte své údaje" "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." "Vítejte zpět!" diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml index 34ba1bd0ed..7a071a285b 100644 --- a/features/login/impl/src/main/res/values-in/translations.xml +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -89,7 +89,7 @@ Coba masuk secara manual, atau pindai kode QR dengan perangkat lain." "%1$s tidak didukung" "Siap untuk memindai" "Buka %1$s di perangkat desktop" - "Klik pada avatar Anda" + "Klik avatar Anda" "Pilih %1$s" "“Tautkan perangkat baru”" "Pindai kode QR dengan perangkat ini" diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index 6835c0a15d..eda603ed7b 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -1,13 +1,13 @@ "Schimbați furnizorul contului" - "Adresa Homeserver-ului" - "Introduceţi un termen de căutare sau o adresă de domeniu." + "Adresa homeserverului" + "Introduceți un termen de căutare sau o adresă de domeniu." "Căutați o companie, o comunitate sau un server privat." "Găsiți un furnizor de cont" - "Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." + "Aici vor apărea conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." "Sunteți pe cale să vă conectați la %s" - "Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." + "Aici vor apărea conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." "Sunteți pe cale să creați un cont pe %s" "Matrix.org este un server mare și gratuit din rețeaua publică Matrix pentru comunicații sigure și descentralizate, administrat de Fundația Matrix.org." "Altul" @@ -16,42 +16,42 @@ "Google Play" "Aplicația Element Pro este necesară pe %1$s. Descărcați-o din magazin." "Este necesar Element Pro" - "Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar." - "Serverul nu este disponibil din cauza unei probleme în fișierul well-known: + "Nu am putut accesa acest furnizor de cont. Verificați că ați introdus corect adresa URL a furnizorului de cont. Dacă adresa URL este corectă, contactați administratorul furnizorului de cont pentru ajutor suplimentar." + "Serverul nu este disponibil din cauza unei probleme în fișierul .well-known: %1$s" - "Furnizorul de cont selectat nu acceptă Sliding sync. Pentru a utiliza funcția „ %1$s ”, este necesară o actualizare a serverului." + "Furnizorul de cont selectat nu acceptă Sliding sync. Pentru a utiliza funcția „%1$s”, este necesar un upgrade al serverului." "%1$s nu are voie să se conecteze la %2$s." "Această aplicație a fost configurată pentru a permite: %1$s." "Furnizorul de cont %1$s nu este permis." - "Adresa URL a homeserver-ului" + "Adresa URL a homeserverului" "Introduceți o adresă de domeniu." - "Autentificare" + "Conectare" "Creați un cont" - "Care este adresa serverului dumneavoastră?" - "Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)." + "Care este adresa serverului dvs.?" + "Dacă contul este furnizat de compania dvs. 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" - "Selectați serverul dumneavoastra" - "Introduceți furnizorul contului dumneavoastră" + "example.com" + "Selectați serverul dvs." + "Introduceți furnizorul contului dvs." "Alegeți un furnizor de cont" "Creați un cont" "Acest cont a fost șters." "Utilizator și/sau parolă incorecte" - "Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”" - "Acest server este configurat pentru a utiliza token-uri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați autentificare bazată pe parolă." - "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OAuth. Te rugăm să contactezi administratorul sau să alegi un alt homeserver." + "Identificatorul de utilizator nu este valid. Format așteptat: „@user:homeserver.org”" + "Acest server este configurat pentru a utiliza tokenuri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați conectarea bazată pe parolă." + "Furnizorul de cont selectat nu acceptă conectarea prin parolă sau OAuth. Contactați administratorul sau alegeți un alt furnizor de cont." "Introduceți detaliile" "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." "Bine ați revenit!" "Conectați-vă la %1$s" - "Deschideți Element Clasic" - "Deschideți Element Classic pe dispozitivul dumneavoastră" + "Deschideți Element Classic" + "Deschideți Element Classic pe dispozitivul dvs." "Accesați Setări > Securitate și confidențialitate" "În Gestionarea cheilor criptografice, selectați Recuperarea mesajelor criptate" - "Urmați instrucțiunile pentru a activa stocarea cheilor" + "Urmați instrucțiunile pentru a activa backupului cheilor" "Reveniți la %1$s" - "Activați stocarea cheilor înainte de a continua către %1$s" + "Activați backupul cheilor înainte de a continua la %1$s" "Versiunea %1$s" "Se verifică contul…" "Conectați-vă manual" @@ -59,11 +59,11 @@ "Conectați-vă cu un cod QR" "Creați un cont" "Bine ați revenit" - "Bine ați venit la cel mai rapid %1$s din toate timpurile. Supraalimentat pentru viteză și simplitate." - "Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate." - "Fii în Elementul tău" + "Bine ați venit la cel mai rapid %1$s din toate timpurile. Superperformant, pentru viteză și simplitate." + "Bun-venit în %1$s. Superperformant, pentru viteză și simplitate." + "Intrați-vă în Element" "Se stabilește o conexiune securizată" - "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." + "Nu a putut fi creată o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." "Și acum?" "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea." "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi." @@ -71,29 +71,29 @@ "Conexiunea nu este sigură" "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." "Introduceți numărul de mai jos pe celălalt dispozitiv" - "Autentificați-vă pe celălalt dispozitiv și apoi încercați din nou sau utilizați un alt dispozitiv care este deja conectat." + "Conectați-vă pe celălalt dispozitiv și apoi încercați din nou sau utilizați un alt dispozitiv care este deja conectat." "Celălalt dispozitiv nu este conectat" - "Autentificarea a fost anulată de pe celălalt dispozitiv." - "Cererea de autentificare a fost anulată" - "Autentificarea a fost refuzată pe celălalt dispozitiv." - "Autentificarea a fost refuzată" + "Conectarea a fost anulată de pe celălalt dispozitiv." + "Cererea de conectare a fost anulată" + "Conectarea a fost refuzată pe celălalt dispozitiv." + "Conectarea a fost refuzată" "Nu trebuie să faceți nimic altceva." "Celălalt dispozitiv este deja conectat" - "Autentificarea a expirat. Vă rugăm să încercați din nou." - "Autentificarea nu a fost finalizată la timp" - "Celălalt dispozitiv nu acceptă autentificarea la %s cu un cod QR. + "Conectarea a expirat. Încercați din nou." + "Conectarea nu a fost finalizată la timp" + "Celălalt dispozitiv nu acceptă conectarea la %s cu un cod QR. -Încercați să vă autentificați manual sau să scanați codul QR cu un alt dispozitiv." - "Formatul codului QR nu este acceptat." - "Furnizorul dumneavoastră de cont nu acceptă %1$s." +Încercați să vă conectați manual sau să scanați codul QR cu un alt dispozitiv." + "Codul QR nu este acceptat." + "Furnizorul contului dvs. nu acceptă %1$s." "%1$s nu este acceptat" "Gata de scanare" "Deschideți %1$s pe un dispozitiv desktop" - "Faceți clic pe avatarul dumneavoastră" + "Faceți click pe avatarul dvs." "Selectați %1$s" "„Conectați un dispozitiv nou”" "Scanați codul QR cu acest dispozitiv" - "Disponibil numai dacă furnizorul dumneavoastră de cont îl acceptă." + "Disponibil numai dacă furnizorul contului dvs. îl acceptă." "Deschideți %1$s pe un alt dispozitiv pentru a obține codul QR" "Utilizați codul QR afișat pe celălalt dispozitiv." "Încercați din nou" @@ -103,14 +103,14 @@ "Permiteți accesul la cameră pentru a scana codul QR" "Scanați codul QR" "Începeți din nou" - "A apărut o eroare neașteptată. Vă rugăm să încercați din nou." + "A apărut o eroare neașteptată. Încercați din nou." "În așteptarea celuilalt dispozitiv" - "Furnizorul dumneavoastră de cont poate cere următorul cod pentru a verifica conectarea." - "Codul dumneavoastră de verificare" + "Furnizorul contului dvs. poate cere următorul cod pentru a verifica conectarea." + "Codul dvs. de verificare" "Schimbați furnizorul contului" "Un server privat pentru angajații Element." "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." - "Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." + "Aici vor apărea conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile." "Sunteți pe cale să vă conectați la %1$s" "Alegeți furnizorul de cont" "Sunteți pe cale să creați un cont pe %1$s" diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml index aca4962922..aaf371f748 100644 --- a/features/login/impl/src/main/res/values-zh/translations.xml +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -3,7 +3,7 @@ "更改账户提供者" "主服务器地址" "输入搜索词或域名地址。" - "搜索公司、社区或私人服务器。" + "搜索公司、社区或私有服务器。" "查找账户提供者" "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。" "你即将登录到 %s" @@ -108,7 +108,7 @@ "你的账户提供者可能会要求你提供以下代码以验证登录。" "你的验证码" "更改账户提供者" - "专为 Element 员工提供的私人服务器。" + "专为 Element 员工提供的私有服务器。" "Matrix 是一个用于安全、去中心化通信的开放网络。" "这是你的对话将存在的地方,就像你使用邮件提供者来存储电子邮件那样。" "即将登录 %1$s" diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index d77081a70f..0d716537f3 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -52,6 +52,8 @@ "Follow the instructions to enable your key storage" "Come back to %1$s" "Enable your key storage before proceeding to %1$s" + "You can add multiple accounts to the app and switch between them easily." + "Sign in to another account" "Version %1$s" "Checking account" "Sign in manually" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt index 702adaaa6d..4949524813 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/classic/DefaultElementClassicConnectionTest.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationSer import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -499,5 +500,6 @@ class DefaultElementClassicConnectionTest : RobolectricTest() { coroutineScope = coroutineScope, matrixAuthenticationService = matrixAuthenticationService, homeServerLoginCompatibilityChecker = homeServerLoginCompatibilityChecker, + dispatchers = testCoroutineDispatchers(), ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt new file mode 100644 index 0000000000..61f22a1fec --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt @@ -0,0 +1,57 @@ +/* + * 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.features.login.impl.screens.confirmaccountprovider + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.AndroidComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.libraries.matrix.api.auth.OAuthDetails +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class ConfirmAccountProviderViewTest : RobolectricTest() { + @Test + fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest { + val eventSink = EventsRecorder(expectEvents = false) + ensureCalledOnce { + setConfirmAccountProviderView( + state = aConfirmAccountProviderState( + eventSink = eventSink, + ), + onBackClick = it, + ) + pressBack() + } + } + + private fun AndroidComposeUiTest.setConfirmAccountProviderView( + state: ConfirmAccountProviderState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(), + onNeedLoginPassword: () -> Unit = EnsureNeverCalled(), + onLearnMoreClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ConfirmAccountProviderView( + state = state, + onBackClick = onBackClick, + onOAuthDetails = onOAuthDetails, + onNeedLoginPassword = onNeedLoginPassword, + onLearnMoreClick = onLearnMoreClick, + ) + } + } +} diff --git a/features/logout/api/build.gradle.kts b/features/logout/api/build.gradle.kts index 4453d37931..9a0d858dba 100644 --- a/features/logout/api/build.gradle.kts +++ b/features/logout/api/build.gradle.kts @@ -14,8 +14,5 @@ android { } dependencies { - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) } diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt index 75c3392ac1..62b4d36bd1 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt @@ -11,7 +11,6 @@ package io.element.android.features.logout.api.direct import io.element.android.libraries.architecture.AsyncAction data class DirectLogoutState( - val canDoDirectSignOut: Boolean, val logoutAction: AsyncAction, val eventSink: (DirectLogoutEvent) -> Unit, ) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt index 936f7d2a58..ca1d9fbd0b 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutStatePreviewParam.kt @@ -23,11 +23,9 @@ open class DirectLogoutStatePreviewParam : PreviewParameterProvider = AsyncAction.Uninitialized, eventSink: (DirectLogoutEvent) -> Unit = {}, ) = DirectLogoutState( - canDoDirectSignOut = canDoDirectSignOut, logoutAction = logoutAction, eventSink = eventSink, ) diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index d5356ced63..612037a618 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -29,19 +29,17 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.workmanager.api) api(projects.features.logout.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.workmanager.test) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt index 4ad8a6c78e..a3cd2f6429 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt @@ -41,6 +41,7 @@ class LogoutPresenter( @Composable override fun present(): LogoutState { val localCoroutineScope = rememberCoroutineScope() + val currentUser by matrixClient.userProfile.collectAsState() val logoutAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } @@ -90,6 +91,7 @@ class LogoutPresenter( } return LogoutState( + currentUser = currentUser, isLastDevice = isLastDevice, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt index 6233c1cb23..dfab1906fb 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt @@ -12,8 +12,10 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser data class LogoutState( + val currentUser: MatrixUser, val isLastDevice: Boolean, val backupState: BackupState, val doesBackupExistOnServer: Boolean, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt index e2d948a7f3..9db884330b 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStatePreviewParam.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.SteadyStateException +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser open class LogoutStatePreviewParam : PreviewParameterProvider { override val values: Sequence @@ -39,10 +41,12 @@ open class LogoutStatePreviewParam : PreviewParameterProvider { backupUploadState = BackupUploadState.Waiting, waitingForALongTime = true, ), + aLogoutState(currentUser = aMatrixUser(id = "@alice:server.org", displayName = null)), ) } fun aLogoutState( + currentUser: MatrixUser = aMatrixUser(), isLastDevice: Boolean = false, backupState: BackupState = BackupState.ENABLED, doesBackupExistOnServer: Boolean = true, @@ -52,6 +56,7 @@ fun aLogoutState( logoutAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (LogoutEvent) -> Unit = {}, ) = LogoutState( + currentUser = currentUser, isLastDevice = isLastDevice, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServer, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt index 90a502fc16..4093be223d 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -11,7 +11,9 @@ package io.element.android.features.logout.impl import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -32,15 +34,22 @@ import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.encryption.SteadyStateException +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=442-34581 + */ @Composable fun LogoutView( state: LogoutState, @@ -54,15 +63,17 @@ fun LogoutView( onBackClick = onBackClick, title = title(state), subTitle = subtitle(state), - iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + iconStyle = BigIcon.Style.Default(CompoundIcons.Mobile()), modifier = modifier, + isScrollable = true, buttons = { Buttons( state = state, onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, onLogoutClick = { eventSink(LogoutEvent.Logout(ignoreSdkError = false)) - } + }, + onCancelClick = onBackClick, ) }, ) { @@ -71,6 +82,8 @@ fun LogoutView( LogoutActionDialog( state.logoutAction, + // Do not show the progress dialog, the progress is rendered in the button. + showProgressDialog = false, onConfirmClick = { eventSink(LogoutEvent.Logout(ignoreSdkError = false)) }, @@ -116,6 +129,7 @@ private fun ColumnScope.Buttons( state: LogoutState, onLogoutClick: () -> Unit, onChangeRecoveryKeyClick: () -> Unit, + onCancelClick: () -> Unit, ) { val logoutAction = state.logoutAction if (state.isLastDevice) { @@ -139,6 +153,12 @@ private fun ColumnScope.Buttons( .testTag(TestTags.signOut), onClick = onLogoutClick, ) + TextButton( + text = stringResource(id = CommonStrings.action_cancel), + enabled = logoutAction !is AsyncAction.Loading, + modifier = Modifier.fillMaxWidth(), + onClick = onCancelClick, + ) } @Composable @@ -149,9 +169,16 @@ private fun Content( Column( modifier = modifier .fillMaxWidth() - .padding(top = 60.dp, start = 20.dp, end = 20.dp), + .padding(top = 8.dp, start = 16.dp, end = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + AccountInfoCard( + AccountInfoCardState( + matrixUser = state.currentUser, + mode = AccountInfoCardMode.Simple, + ) + ) + Spacer(modifier = Modifier.height(16.dp)) when (state.backupUploadState) { is BackupUploadState.Uploading -> { LinearProgressIndicator( diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt index 901e4e9654..454a7a25fb 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -27,6 +27,7 @@ class DefaultDirectLogoutView : DirectLogoutView { val eventSink = state.eventSink LogoutActionDialog( state.logoutAction, + showProgressDialog = true, onConfirmClick = { eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) }, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt index 79798dd2ab..0b18856ff4 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenter.kt @@ -10,28 +10,22 @@ package io.element.android.features.logout.impl.direct import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.impl.tools.isBackingUp import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.encryption.BackupUploadState -import io.element.android.libraries.matrix.api.encryption.EncryptionService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @Inject class DirectLogoutPresenter( private val matrixClient: MatrixClient, - private val encryptionService: EncryptionService, ) : Presenter { @Composable override fun present(): DirectLogoutState { @@ -41,13 +35,6 @@ class DirectLogoutPresenter( mutableStateOf(AsyncAction.Uninitialized) } - val backupUploadState: BackupUploadState by remember { - encryptionService.waitForBackupUploadSteadyState() - } - .collectAsState(initial = BackupUploadState.Unknown) - - val isLastDevice by encryptionService.isLastDevice.collectAsState() - fun handleEvent(event: DirectLogoutEvent) { when (event) { is DirectLogoutEvent.Logout -> { @@ -64,8 +51,6 @@ class DirectLogoutPresenter( } return DirectLogoutState( - canDoDirectSignOut = !isLastDevice && - !backupUploadState.isBackingUp(), logoutAction = logoutAction.value, eventSink = ::handleEvent, ) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt index 0222a991db..ca9e6d9a16 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutActionDialog( state: AsyncAction, + showProgressDialog: Boolean, onConfirmClick: () -> Unit, onForceLogoutClick: () -> Unit, onDismissDialog: () -> Unit, @@ -31,8 +32,11 @@ fun LogoutActionDialog( onSubmitClick = onConfirmClick, onDismiss = onDismissDialog ) - is AsyncAction.Loading -> - ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + is AsyncAction.Loading -> { + if (showProgressDialog) { + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + } + } is AsyncAction.Failure -> RetryDialog( title = stringResource(id = CommonStrings.dialog_title_error), diff --git a/features/logout/impl/src/main/res/values-ro/translations.xml b/features/logout/impl/src/main/res/values-ro/translations.xml index 1f1ff9e07a..12d6aad902 100644 --- a/features/logout/impl/src/main/res/values-ro/translations.xml +++ b/features/logout/impl/src/main/res/values-ro/translations.xml @@ -1,18 +1,18 @@ - "Sunteți sigur că vreți să vă deconectați?" - "Deconectați-vă" - "Deconectați-vă" - "Deconectare în curs…" - "Acesta este singurul dumneavoastră dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restaura mesajele criptate data viitoare când vă conectați." - "Sunteți pe cale să vă pierdeți accesul la mesajele dumneavoastră criptate." - "Cheile dumneavoastră erau încă în curs de backup atunci când ați fost deconectat. Reconectați-vă pentru ca cheile dumneavoastră să poată fi salvate înainte de a elimina acest dispozitiv." - "Cheile dumneavoastră sunt încă în curs de backup" - "Vă rugăm să așteptați până la finalizarea acestui proces înainte de a vă deconecta." - "Cheile dumneavoastră sunt încă în curs de backup" - "Deconectați-vă" - "Acesta este singurul dumneavoastră dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restaura chat-urile criptate data viitoare când vă conectați." - "Sunteți pe cale să pierdeți accesul la mesajele dumneavoastră criptate" - "Acesta este singurul dumneavoastră dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restaura mesajele criptate data viitoare când vă conectați." + "Sigur doriți să eliminați acest dispozitiv?" + "Eliminați acest dispozitiv" + "Eliminați acest dispozitiv" + "Se elimină dispozitivul…" + "Acesta este singurul dvs. dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restabili conversațiile criptate data viitoare când vă conectați." + "Sunteți pe cale să pierdeți accesul la conversațiile dvs. criptate" + "Backupul cheilor dvs. era încă în curs atunci când v-ați deconectat. Reconectați-vă pentru ca cheile dvs. să poată fi salvate înainte de a elimina acest dispozitiv." + "Backupul cheilor dvs. încă nu s-a finalizat" + "Vă rugăm să așteptați până la finalizarea acestui proces înainte de a elimina acest dispozitiv." + "Backupul cheilor dvs. încă nu s-a finalizat" + "Eliminați acest dispozitiv" + "Acesta este singurul dvs. dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restabili conversațiile criptate data viitoare când vă conectați." + "Sunteți pe cale să pierdeți accesul la conversațiile dvs. criptate" + "Acesta este singurul dvs. dispozitiv. Dacă îl eliminați, veți avea nevoie de o cheie de recuperare pentru a vă confirma identitatea digitală și a restabili conversațiile criptate data viitoare când vă conectați." "Asigurați-vă că aveți acces la cheia de recuperare înainte de a elimina acest dispozitiv." diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 5b14bc5cc3..f22eef1bc3 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -20,7 +20,9 @@ import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser 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.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.workmanager.api.WorkManagerRequestType @@ -39,11 +41,18 @@ class LogoutPresenterTest { @Test fun `present - initial state`() = runTest { - val presenter = createLogoutPresenter() + val presenter = createLogoutPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + userDisplayName = "Alice", + userAvatarUrl = "mxc://avatar", + ), + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitFirstItem() + assertThat(initialState.currentUser).isEqualTo(MatrixUser(A_SESSION_ID, "Alice", "mxc://avatar")) assertThat(initialState.isLastDevice).isFalse() assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN) assertThat(initialState.doesBackupExistOnServer).isTrue() diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt index f69ec8f0ca..4ec9bb4811 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt @@ -13,8 +13,11 @@ package io.element.android.features.logout.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.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled @@ -25,8 +28,22 @@ import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test +import org.robolectric.annotation.Config class LogoutViewTest : RobolectricTest() { + @Test + @Config(qualifiers = "h1024dp") + fun `the current user is displayed`() = runAndroidComposeUiTest { + setLogoutView( + aLogoutState( + currentUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink = EventsRecorder(expectEvents = false), + ), + ) + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("@alice:server.org").assertIsDisplayed() + } + @Test fun `clicking on logout sends a LogoutEvent`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -88,10 +105,24 @@ class LogoutViewTest : RobolectricTest() { eventSink = eventsRecorder ), ) - clickOn(CommonStrings.action_cancel) + clickOn(CommonStrings.action_cancel, inDialog = true) eventsRecorder.assertSingle(LogoutEvent.CloseDialogs) } + @Test + fun `clicking on cancel invoke back callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + setLogoutView( + aLogoutState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + clickOn(CommonStrings.action_cancel) + } + } + @Test fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder(expectEvents = false) diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt index d11c31cf1b..ff0420e04d 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DirectLogoutPresenterTest.kt @@ -16,13 +16,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutEvent import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.encryption.BackupUploadState -import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.WarmUpRule -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -38,44 +34,6 @@ class DirectLogoutPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.canDoDirectSignOut).isTrue() - assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) - } - } - - @Test - fun `present - initial state - last session`() = runTest { - val presenter = createDirectLogoutPresenter( - encryptionService = FakeEncryptionService().apply { - emitIsLastDevice(true) - } - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitFirstItem() - assertThat(initialState.canDoDirectSignOut).isFalse() - assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) - } - } - - @Test - fun `present - initial state - backing up`() = runTest { - val encryptionService = FakeEncryptionService() - encryptionService.givenWaitForBackupUploadSteadyStateFlow( - flow { - emit(BackupUploadState.Waiting) - } - ) - val presenter = createDirectLogoutPresenter( - encryptionService = encryptionService - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - skipItems(1) - val initialState = awaitFirstItem() - assertThat(initialState.canDoDirectSignOut).isFalse() assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -180,9 +138,7 @@ class DirectLogoutPresenterTest { private fun createDirectLogoutPresenter( matrixClient: MatrixClient = FakeMatrixClient(), - encryptionService: EncryptionService = FakeEncryptionService(), ): DirectLogoutPresenter = DirectLogoutPresenter( matrixClient = matrixClient, - encryptionService = encryptionService, ) } diff --git a/features/messages/api/build.gradle.kts b/features/messages/api/build.gradle.kts index 029ef624e0..20e05a1798 100644 --- a/features/messages/api/build.gradle.kts +++ b/features/messages/api/build.gradle.kts @@ -19,7 +19,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.preferences.api) api(projects.libraries.textcomposer.impl) } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 882d9f1f97..cd7ce3703a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.emoji.api) - implementation(projects.libraries.roomselect.api) implementation(projects.libraries.slashcommands.api) implementation(projects.libraries.audio.api) implementation(projects.libraries.voiceplayer.api) @@ -63,22 +62,17 @@ dependencies { implementation(projects.libraries.uiCommon) 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) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) implementation(libs.coil.compose) - implementation(libs.datetime) implementation(libs.jsoup) - implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.exifinterface) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.sigpwned.emoji4j) - implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.zoomableimage) implementation(libs.matrix.emojibase.bindings) implementation(projects.features.knockrequests.api) @@ -106,7 +100,6 @@ dependencies { testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.testtags) testImplementation(projects.features.poll.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.emoji.impl) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt index 38aa5b75ce..7a209ad445 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvent.kt @@ -23,6 +23,7 @@ sealed interface MessagesEvent { data object StopLiveLocationShare : MessagesEvent data object ShowLiveLocationShare : MessagesEvent data object MarkAsFullyReadAndExit : MessagesEvent + data object ConfirmSelectionAction : MessagesEvent } enum class InviteDialogAction { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 40d62313ec..c81691b004 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -181,7 +181,7 @@ class MessagesFlowNode( @Parcelize data class ForwardEvent( - val eventId: EventId, + val eventIds: List, @IgnoredOnParcel val timelineProvider: TimelineProvider? = null, ) : NavTarget @@ -306,8 +306,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { @@ -464,7 +464,7 @@ class MessagesFlowNode( is NavTarget.ForwardEvent -> { // If no timeline provider is received, assume the live timeline should be used val timelineProvider = navTarget.timelineProvider ?: timelineController - val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider) + val params = ForwardEntryPoint.Params(navTarget.eventIds, timelineProvider) val callback = object : ForwardEntryPoint.Callback { override fun onDone(roomIds: List) { backstack.pop() @@ -551,7 +551,7 @@ class MessagesFlowNode( } override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { - backstack.push(NavTarget.ForwardEvent(eventId = eventId, timelineProvider = timelineProvider)) + backstack.push(NavTarget.ForwardEvent(eventIds = listOf(eventId), timelineProvider = timelineProvider)) } override fun navigateToThread(threadRootId: ThreadId) { @@ -617,8 +617,8 @@ class MessagesFlowNode( backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) { - backstack.push(NavTarget.ForwardEvent(eventId, timelineProvider)) + override fun handleForwardEventClick(eventIds: List, timelineProvider: TimelineProvider) { + backstack.push(NavTarget.ForwardEvent(eventIds, timelineProvider)) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { @@ -776,7 +776,7 @@ class MessagesFlowNode( assetType = event.content.assetType, ) } - NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } + NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable().value } } else { displayVulkanNotSupportedError = true null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index 424d8bcd21..de7d650a15 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -19,7 +19,7 @@ import kotlinx.collections.immutable.ImmutableList interface MessagesNavigator { fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) + fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToEditPoll(eventId: EventId) fun navigateToPreviewAttachments(attachments: ImmutableList, inReplyToEventId: EventId?) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 62830ef1ba..4ed612614b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -138,7 +138,7 @@ class MessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) + fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -221,8 +221,8 @@ class MessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - callback.forwardEvent(eventId, timelineProvider) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + callback.forwardEvents(eventIds, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 98e595c110..5ebdd4ef19 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -8,7 +8,6 @@ package io.element.android.features.messages.impl -import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -39,6 +38,8 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerState import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState 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.TimelineState @@ -53,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.captionOrNull import io.element.android.features.messages.impl.timeline.model.event.htmlCaptionOrNull import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState +import io.element.android.features.messages.impl.timeline.selectedEventIdsInTimelineOrder import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvent @@ -84,6 +86,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map @@ -258,6 +261,17 @@ class MessagesPresenter( redactEventAction = redactEventAction, ) } + is MessagesEvent.ConfirmSelectionAction -> { + val selection = timelineState.selectionState as? SelectionState.Active + if (selection != null) { + when (selection.action) { + SelectionAction.Forward -> { + navigator.forwardEvents(timelineState.selectedEventIdsInTimelineOrder(), timelineController) + } + } + timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + } is MessagesEvent.ConfirmRedact -> { val confirming = redactEventAction.value as? MessagesState.ConfirmingRedaction redactEventAction.value = AsyncAction.Uninitialized @@ -414,21 +428,27 @@ class MessagesPresenter( } } TimelineItemAction.ViewSource -> handleShowDebugInfoAction(targetEvent) - TimelineItemAction.Forward -> handleForwardAction(targetEvent) + TimelineItemAction.Forward -> { + val eventId = targetEvent.eventId ?: return@launch + if (featureFlagService.isFeatureEnabled(FeatureFlags.MessageMultiSelect)) { + timelineState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, eventId)) + } else { + navigator.forwardEvents(listOf(eventId), timelineController) + } + } TimelineItemAction.ReportContent -> handleReportAction(targetEvent) TimelineItemAction.EndPoll -> handleEndPollAction(targetEvent, timelineState) TimelineItemAction.Pin -> handlePinAction(targetEvent) TimelineItemAction.Unpin -> handleUnpinAction(targetEvent) TimelineItemAction.ViewInTimeline -> Unit TimelineItemAction.RetrySending -> handleRetrySending(targetEvent) + TimelineItemAction.AbortSending -> handleActionAbortSend(targetEvent) } } private suspend fun handleRetrySending(targetEvent: TimelineItem.Event) { - val sendHandle = targetEvent.sendhandle ?: return Unit.also { - Timber.w("No send handle for event ${targetEvent.eventOrTransactionId}") - } - sendHandle.retry() + val (sendTarget, _) = targetEvent.pendingSend() ?: return + timelineController.retrySend(targetEvent.eventOrTransactionId, sendTarget) .onSuccess { Timber.d("Succeed to add the message back to the send queue") } @@ -518,13 +538,18 @@ class MessagesPresenter( private suspend fun handleActionRedact(event: TimelineItem.Event, redactEventAction: MutableState>) { val eventId = event.eventId if (eventId == null) { - // The message was never sent, so there is nobody to give a reason to. - redact(event.eventOrTransactionId, reason = null) + // The message (or its edit/redaction) was never sent, just remove it from the send queue. + handleActionAbortSend(event) } else { redactEventAction.value = MessagesState.ConfirmingRedaction(eventId) } } + private suspend fun handleActionAbortSend(event: TimelineItem.Event) { + val sendTarget = event.pendingSend()?.first ?: SendTarget.Event + timelineController.abortSend(event.eventOrTransactionId, sendTarget).onFailure { Timber.e(it) } + } + private suspend fun redact(eventOrTransactionId: EventOrTransactionId, reason: String?) { timelineController.invokeOnCurrentTimeline { redactEvent(eventOrTransactionId = eventOrTransactionId, reason = reason) @@ -613,11 +638,6 @@ class MessagesPresenter( navigator.navigateToEventDebugInfo(event.eventId, event.debugInfo) } - private fun handleForwardAction(event: TimelineItem.Event) { - if (event.eventId == null) return - navigator.forwardEvent(eventId = event.eventId, timelineProvider = timelineController) - } - private fun handleReportAction(event: TimelineItem.Event) { if (event.eventId == null) return navigator.navigateToReportMessage(event.eventId, event.senderId) @@ -634,8 +654,9 @@ class MessagesPresenter( event.eventId ?: return room.getPermalinkFor(event.eventId).fold( onSuccess = { permalink -> - clipboardHelper.copyPlainText(permalink) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) + clipboardHelper.copyPlainText(permalink) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) + } }, onFailure = { Timber.e(it, "Failed to get permalink for event ${event.eventId}") @@ -650,16 +671,14 @@ class MessagesPresenter( is TimelineItemStateContent -> event.content.body else -> return } - clipboardHelper.copyPlainText(content) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + clipboardHelper.copyPlainText(content) { snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied)) } } private fun handleCopyCaption(event: TimelineItem.Event) { val content = event.content.captionOrNull() ?: return - clipboardHelper.copyPlainText(content) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + clipboardHelper.copyPlainText(content) { snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index f3ece96c2e..e5c312132d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -8,6 +8,7 @@ package io.element.android.features.messages.impl +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -77,24 +78,31 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerView import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBannerViewDefaults import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineView import io.element.android.features.messages.impl.timeline.aGroupedEvents import io.element.android.features.messages.impl.timeline.aTimelineItemDaySeparator import io.element.android.features.messages.impl.timeline.aTimelineItemEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.canApplyTo import io.element.android.features.messages.impl.timeline.components.CallMenuItem import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvent +import io.element.android.features.messages.impl.timeline.isSelectionModeActive import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.features.messages.impl.timeline.selectedCount import io.element.android.features.messages.impl.timeline.sendfailure.SendFailureDialogView import io.element.android.features.messages.impl.topbars.MessagesViewTopBar +import io.element.android.features.messages.impl.topbars.SelectionModeTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog @@ -134,6 +142,7 @@ import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet import timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -178,6 +187,18 @@ fun MessagesView( block() } + // Entering selection mode collapses the composer, so dismiss the keyboard too. hideKeyboard + // triggers the IME hide animation, keeping it in sync with the composer collapse. + LaunchedEffect(state.timelineState.isSelectionModeActive) { + if (state.timelineState.isSelectionModeActive) { + localView.hideKeyboard() + } + } + + BackHandler(enabled = state.timelineState.isSelectionModeActive) { + state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) + } + fun onContentClick(event: TimelineItem.Event) { Timber.v("onMessageClick= ${event.id}") val eventId = event.eventId @@ -234,34 +255,50 @@ fun MessagesView( Scaffold( contentWindowInsets = scaffoldScrollableContentInsets, topBar = { - if (state.timelineState.timelineMode is Timeline.Mode.Thread) { - ThreadTopBar( - roomName = state.roomName, - roomAvatarData = state.roomAvatar, - heroes = state.heroes, - isTombstoned = state.isTombstoned, - onBackClick = onBackClick, - ) - } else { - MessagesViewTopBar( - roomName = state.roomName, - roomAvatar = state.roomAvatar, - isTombstoned = state.isTombstoned, - heroes = state.heroes, - dmUserIdentityState = state.dmUserVerificationState, - sharedHistoryIcon = state.topBarSharedHistoryIcon, - dmUserStatus = state.dmUserStatus, - onBackClick = { hidingKeyboard { onBackClick() } }, - onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, - menuActions = { - MessagesMenuActions( - displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, - roomCallState = state.roomCallState, - onJoinCallClick = onJoinCallClick, - onThreadsListClick = onThreadsListClick - ) - } - ) + when { + state.timelineState.isSelectionModeActive -> { + val selection = state.timelineState.selectionState as SelectionState.Active + SelectionModeTopBar( + selectedCount = state.timelineState.selectedCount, + action = selection.action, + onCancelClick = { + state.timelineState.eventSink(TimelineEvent.ExitSelectionMode) + }, + onConfirmClick = { + state.eventSink(MessagesEvent.ConfirmSelectionAction) + }, + ) + } + state.timelineState.timelineMode is Timeline.Mode.Thread -> { + ThreadTopBar( + roomName = state.roomName, + roomAvatarData = state.roomAvatar, + heroes = state.heroes, + isTombstoned = state.isTombstoned, + onBackClick = onBackClick, + ) + } + else -> { + MessagesViewTopBar( + roomName = state.roomName, + roomAvatar = state.roomAvatar, + isTombstoned = state.isTombstoned, + heroes = state.heroes, + dmUserIdentityState = state.dmUserVerificationState, + sharedHistoryIcon = state.topBarSharedHistoryIcon, + dmUserStatus = state.dmUserStatus, + onBackClick = { hidingKeyboard { onBackClick() } }, + onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, + menuActions = { + MessagesMenuActions( + displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads, + roomCallState = state.roomCallState, + onJoinCallClick = onJoinCallClick, + onThreadsListClick = onThreadsListClick + ) + } + ) + } } }, content = { padding -> @@ -449,7 +486,7 @@ fun MessagesView( onRemoveMessage = { event -> state.eventSink( MessagesEvent.HandleAction( - action = TimelineItemAction.Redact, + action = TimelineItemAction.AbortSending, event = event, ) ) @@ -728,6 +765,40 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStatePreviewParam::cl ) } +@PreviewsDayNight +@Composable +internal fun MessagesViewSelectionModePreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + val selectedEventIds = timelineItems + .filterIsInstance() + .filter { SelectionAction.Forward.canApplyTo(it) } + .take(2) + .mapNotNull { it.eventId } + .toImmutableSet() + MessagesView( + state = aMessagesState( + timelineState = aTimelineState( + timelineItems = timelineItems, + selectionState = SelectionState.Active(SelectionAction.Forward, selectedEventIds), + ), + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onGalleryEventItemClick = { _, _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = { }, + forceJumpToBottomVisibility = true, + knockRequestsBannerView = {}, + customReactionBottomSheet = {}, + onThreadsListClick = {}, + ) +} + @Preview @Composable internal fun MessagesViewA11yPreview() = ElementPreview { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt index 3c64223e59..e78b2ac5e2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemAction.kt @@ -37,6 +37,7 @@ enum class TimelineItemAction( Pin(CommonStrings.action_pin, CompoundDrawables.ic_compound_pin), Unpin(CommonStrings.action_unpin, CompoundDrawables.ic_compound_unpin), - // Note: this action is not used in UI for now + // Note: these actions are not used in UI for now RetrySending(CommonStrings.action_retry, CompoundDrawables.ic_compound_send_solid), + AbortSending(CommonStrings.action_cancel, CompoundDrawables.ic_compound_send_solid), } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt index 2f6fa4e4e0..3f0abf1b8d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/model/TimelineItemActionComparator.kt @@ -32,8 +32,9 @@ class TimelineItemActionComparator : Comparator { TimelineItemAction.ViewSource, TimelineItemAction.ReportContent, TimelineItemAction.Redact, - // Not rendered in the action list, but has to be listed here for exhaustiveness. + // Not rendered in the action list, but have to be listed here for exhaustiveness. TimelineItemAction.RetrySending, + TimelineItemAction.AbortSending, ) override fun compare(o1: TimelineItemAction, o2: TimelineItemAction): Int { 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 86b8a85557..0f9ca3d7c1 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 @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -92,7 +91,6 @@ import kotlin.time.Duration.Companion.milliseconds /** * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentsPreviewView( state: AttachmentsPreviewState, @@ -263,7 +261,7 @@ private fun AttachmentSendStateView( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable private fun AttachmentPreviewContent( state: AttachmentsPreviewState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt index 70a3375fa8..a14f2804b2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -82,7 +81,6 @@ private val maxHandleTouchRadius = 56.dp /** * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentImageEditorView( state: AttachmentImageEditorState, 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 2974b99696..2c922db2e4 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 @@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes @@ -145,6 +147,7 @@ class MessageComposerPresenter( private val featureFlagService: FeatureFlagService, private val contentScannerService: ContentScannerService, private val contentValidationCache: EventContentValidationCache, + private val dispatchers: CoroutineDispatchers, ) : Presenter { @AssistedFactory interface Factory { @@ -184,31 +187,28 @@ class MessageComposerPresenter( val cameraPermissionState = cameraPermissionPresenter.present() - val canShareLocation = remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - canShareLocation.value = locationService.isServiceAvailable() - } + val canShareLocation by remember { locationService.isServiceAvailable() }.collectAsState() val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages) .collectAsState(initial = false) val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> - handlePickedMedia(uri, mimeType) + localCoroutineScope.handlePickedMedia(uri, mimeType) } val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> - handlePickedMediaList(uris) + localCoroutineScope.handlePickedMediaList(uris) } val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> - handlePickedMediaList(uris, sendAsFile = true) + localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true) } val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) + localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Jpeg) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg) } val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Mp4) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4) } val isFullScreen = rememberSaveable { mutableStateOf(false) @@ -286,17 +286,16 @@ class MessageComposerPresenter( } is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - sessionCoroutineScope.sendAttachment( - attachment = Attachment.Media( - localMedia = localMediaFactory.createFromUri( - uri = event.uri, - mimeType = null, - name = null, - formattedFileSize = null - ), - ), - inReplyToEventId = inReplyToEventId, - ) + sessionCoroutineScope.launch { + val localMedia = createLocalMedia( + uri = event.uri, + mimeType = null, + ) + sendAttachment( + attachment = Attachment.Media(localMedia), + inReplyToEventId = inReplyToEventId, + ) + } resetComposerModeAfterAttaching() } @@ -431,7 +430,7 @@ class MessageComposerPresenter( isInThreadTimeline = isInThread, showAttachmentSourcePicker = showAttachmentSourcePicker, showTextFormatting = showTextFormatting, - canShareLocation = canShareLocation.value, + canShareLocation = canShareLocation, suggestions = suggestions.toImmutableList(), resolveMentionDisplay = resolveMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, @@ -640,17 +639,28 @@ class MessageComposerPresenter( } } - private fun handlePickedMedia( - uri: Uri?, - mimeType: String? = null, - sendAsFile: Boolean = false, - ) { - uri ?: return - val localMedia = localMediaFactory.createFromUri( + // Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread + private suspend fun createLocalMedia( + uri: Uri, + mimeType: String?, + ) = withContext(dispatchers.io) { + localMediaFactory.createFromUri( uri = uri, mimeType = mimeType, name = null, - formattedFileSize = null + formattedFileSize = null, + ) + } + + private fun CoroutineScope.handlePickedMedia( + uri: Uri?, + mimeType: String? = null, + sendAsFile: Boolean = false, + ) = launch { + uri ?: return@launch + val localMedia = createLocalMedia( + uri = uri, + mimeType = mimeType, ) val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile) val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId @@ -659,7 +669,7 @@ class MessageComposerPresenter( resetComposerModeAfterAttaching() } - private fun handlePickedMediaList( + private fun CoroutineScope.handlePickedMediaList( uris: List, sendAsFile: Boolean = false, ) { @@ -668,19 +678,19 @@ class MessageComposerPresenter( handlePickedMedia(uris.first(), sendAsFile = sendAsFile) return } - val attachments = uris.map { uri -> - val localMedia = localMediaFactory.createFromUri( - uri = uri, - mimeType = null, - name = null, - formattedFileSize = null, - ) - Attachment.Media(localMedia, sendAsFile = sendAsFile) - }.toImmutableList() - val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) + launch { + val attachments = uris.map { uri -> + val localMedia = createLocalMedia( + uri = uri, + mimeType = null, + ) + Attachment.Media(localMedia, sendAsFile = sendAsFile) + }.toImmutableList() + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) - resetComposerModeAfterAttaching() + resetComposerModeAfterAttaching() + } } private fun resetComposerModeAfterAttaching() { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 5c6980c8bb..2391766203 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -26,8 +25,9 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter 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 -import io.element.android.libraries.androidutils.system.copyToClipboard +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.system.openUrlInExternalApp +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.RoomScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer @@ -53,6 +53,8 @@ class PinnedMessagesListNode( private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val permalinkParser: PermalinkParser, private val emojiPickerRenderer: EmojiPickerRenderer, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { interface Callback : Plugin { fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) @@ -114,7 +116,6 @@ class PinnedMessagesListNode( LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, ) { val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val view = LocalView.current val state = presenter.present() PinnedMessagesListView( @@ -132,10 +133,9 @@ class PinnedMessagesListNode( view.performHapticFeedback( HapticFeedbackConstants.LONG_PRESS ) - context.copyToClipboard( - text = it.url, - toastMessage = toastMessage, - ) + clipboardHelper.copyPlainText(it.url) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } }, emojiPickerRenderer = emojiPickerRenderer, modifier = modifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt index 298a783e97..9783c679d3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListView.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.plus import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,6 +38,7 @@ import io.element.android.features.messages.impl.timeline.components.event.Timel import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.poll.api.pollcontent.PollTitleView @@ -106,7 +106,6 @@ fun PinnedMessagesListView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PinnedMessagesListTopBar( state: PinnedMessagesListState, @@ -291,6 +290,7 @@ private fun PinnedMessagesListLoaded( onContentLayoutChange = onContentLayoutChange ) }, + selectionData = TimelineItemSelectionData(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt index a3cf4f09da..33575ddf66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -43,7 +42,6 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportMessageView( state: ReportMessageState, 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 34f2b1fc74..851438ae5c 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 @@ -143,7 +143,7 @@ class ThreadedMessagesNode( fun navigateToRoomMemberDetails(userId: UserId) fun handlePermalinkClick(data: PermalinkData) fun navigateToEventDebugInfo(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun handleForwardEventClick(eventId: EventId, timelineProvider: TimelineProvider) + fun handleForwardEventClick(eventIds: List, timelineProvider: TimelineProvider) fun navigateToReportMessage(eventId: EventId, senderId: UserId) fun navigateToSendLocation() fun navigateToCreatePoll() @@ -227,8 +227,8 @@ class ThreadedMessagesNode( callback.navigateToEventDebugInfo(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - callback.handleForwardEventClick(eventId, timelineProvider) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + callback.handleForwardEventClick(eventIds, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt index c38da19a67..a47cdc091f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/list/ThreadsListView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -74,7 +73,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ThreadsListView( state: ThreadsListState, @@ -266,10 +264,12 @@ private fun ThreadListItemRow( ) } - UnreadIndicatorAtom( - size = 14.dp, - isVisible = hasUnreadNotifications, - ) + if (hasUnreadNotifications) { + UnreadIndicatorAtom( + dotIndicatorSize = 14.dp, + count = 0, + ) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt new file mode 100644 index 0000000000..3c44839468 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/SelectionState.kt @@ -0,0 +1,88 @@ +/* + * 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 + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import io.element.android.compound.R +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.canBeForwarded +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet + +/** Maximum number of messages that can be selected at once. */ +const val MAX_SELECTION_COUNT = 10 + +@Immutable +sealed interface SelectionState { + /** Selection mode is not active. */ + data object Disabled : SelectionState + + /** + * Selection mode is active for [action], with the given selection. + * [selectedEventIds] can be empty: selection mode is never exited automatically, only when the user explicitly leaves it. + */ + data class Active( + val action: SelectionAction, + val selectedEventIds: ImmutableSet, + ) : SelectionState +} + +/** + * An action which can be performed on several timeline events at once, and which selection mode can be entered for. + */ +enum class SelectionAction(@field:StringRes val titleRes: Int, @field:DrawableRes val iconRes: Int) { + Forward(CommonStrings.action_forward, R.drawable.ic_compound_forward), +} + +/** + * Whether [event] can be part of a selection made for this action. + * Each action uses the same criteria as the single event action it mirrors. + */ +fun SelectionAction.canApplyTo(event: TimelineItem.Event): Boolean = when (this) { + SelectionAction.Forward -> event.isRemote && event.content.canBeForwarded() +} + +/** + * This selection without [eventIds]. + * Selection mode stays active even when nothing remains selected, see [SelectionState.Active]. + */ +fun SelectionState.deselect(eventIds: Set): SelectionState = when (this) { + SelectionState.Disabled -> this + is SelectionState.Active -> copy(selectedEventIds = (selectedEventIds - eventIds).toImmutableSet()) +} + +/** + * The ids of the selected events which [SelectionState.Active.action] cannot be applied to anymore, for instance because the message has been + * deleted. They are meant to be given to [deselect]. + */ +fun SelectionState.eventIdsToDeselect(timelineItems: List): Set { + val selection = this as? SelectionState.Active ?: return emptySet() + if (selection.selectedEventIds.isEmpty()) return emptySet() + val result = mutableSetOf() + + fun process(event: TimelineItem.Event) { + val eventId = event.eventId ?: return + if (eventId in selection.selectedEventIds && !selection.action.canApplyTo(event)) { + result.add(eventId) + } + } + + timelineItems.forEach { item -> + when (item) { + is TimelineItem.Event -> process(item) + // A deleted message can be folded into a group of deleted messages, so look inside the groups too. + is TimelineItem.GroupedEvents -> item.events.forEach(::process) + is TimelineItem.Virtual -> Unit + } + } + return result +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt index 05f7a06054..b2639d08c6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineController.kt @@ -23,6 +23,8 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import io.element.android.libraries.matrix.api.timeline.item.SendTarget +import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,6 +34,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.getAndUpdate @@ -134,6 +137,14 @@ class TimelineController( } } + suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return currentTimelineFlow.first().retrySend(eventOrTransactionId, sendTarget) + } + + suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return currentTimelineFlow.first().abortSend(eventOrTransactionId, sendTarget) + } + private val currentTimelineFlow = combine(liveTimelineFlow, detachedTimelineFlow) { live, detached -> when { detached.isPresent -> detached.get() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 66807b74f2..fdd1efa620 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -28,6 +28,10 @@ sealed interface TimelineEvent { data object HideShieldDialog : TimelineEvent data object MarkAllAsRead : TimelineEvent + data class EnterSelectionMode(val action: SelectionAction, val eventId: EventId) : TimelineEvent + data class ToggleSelection(val eventId: EventId) : TimelineEvent + data object ExitSelectionMode : TimelineEvent + data class CopyToClipboard(val text: String) : TimelineEvent /** * Events coming from a timeline item. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 4580f77d59..1888864311 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -44,8 +44,12 @@ import io.element.android.features.messages.impl.voicemessages.timeline.Redacted import io.element.android.features.poll.api.actions.EndPollAction import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.roomcall.api.RoomCallState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags @@ -60,6 +64,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom @@ -68,13 +73,17 @@ import io.element.android.services.analytics.api.finishLongRunningTransaction import io.element.android.services.analyticsproviders.api.AnalyticsUserData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -100,10 +109,13 @@ class TimelinePresenter( private val typingNotificationPresenter: Presenter, private val roomCallStatePresenter: Presenter, private val featureFlagService: FeatureFlagService, + private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val markAsFullyRead: MarkAsFullyRead, private val timelineProtectionPresenter: Presenter, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { private val tag = "TimelinePresenter" @@ -167,6 +179,18 @@ class TimelinePresenter( value = featureFlagService.isFeatureEnabled(FeatureFlags.NewTimelineEventRenderer) } + val isMultiSelectEnabled by produceState(false) { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageMultiSelect).collect { value = it } + } + var selectionState by remember { mutableStateOf(SelectionState.Disabled) } + + // When the feature flag is turned off, clear any active selection. + LaunchedEffect(isMultiSelectEnabled) { + if (!isMultiSelectEnabled) { + selectionState = SelectionState.Disabled + } + } + val timelineProtectionState = timelineProtectionPresenter.present() fun handleEvent(event: TimelineEvent) { @@ -247,6 +271,26 @@ class TimelinePresenter( is TimelineEvent.JumpToLive -> { timelineController.focusOnLive() } + is TimelineEvent.EnterSelectionMode -> { + if (!isMultiSelectEnabled) return + selectionState = SelectionState.Active(event.action, persistentSetOf(event.eventId)) + } + is TimelineEvent.ToggleSelection -> { + val current = selectionState as? SelectionState.Active ?: return + val selected = current.selectedEventIds + selectionState = when { + // Deselecting the last event does not exit selection mode: it is only left explicitly, as on iOS. + event.eventId in selected -> current.deselect(setOf(event.eventId)) + selected.size >= MAX_SELECTION_COUNT -> { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.screen_room_maximum_messages_selected)) + current + } + else -> current.copy(selectedEventIds = (selected + event.eventId).toImmutableSet()) + } + } + is TimelineEvent.ExitSelectionMode -> { + selectionState = SelectionState.Disabled + } TimelineEvent.HideShieldDialog -> messageShieldDialogData.value = null TimelineEvent.MarkAllAsRead -> sessionCoroutineScope.launch { val latestEventId = room.liveTimeline.getLatestEventId().getOrElse { @@ -258,7 +302,9 @@ class TimelinePresenter( } is TimelineEvent.ShowShieldDialog -> messageShieldDialogData.value = event.messageShieldData is TimelineEvent.ShowSendFailureDialog -> { - val sendStateFailure = event.event.localSendState as? LocalEventSendState.Failed ?: return + val (_, sendStateFailure) = event.event.pendingSend() ?: return + if (sendStateFailure !is LocalEventSendState.Failed) return + when (sendStateFailure) { is LocalEventSendState.Failed.VerifiedUser -> { // defer to the resolveVerifiedUserSendFailureState to handle this case, @@ -315,6 +361,11 @@ class TimelinePresenter( is TimelineEvent.ValidateMedia -> { timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState)) } + is TimelineEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } @@ -331,6 +382,14 @@ class TimelinePresenter( } .launchIn(this) + // Drop the selected events which the action cannot be applied to anymore, for instance because the message has been deleted. + timelineItemsFactory.timelineItems + .map { items -> selectionState.eventIdsToDeselect(items) } + .filter { eventIdsToDeselect -> eventIdsToDeselect.isNotEmpty() } + .flowOn(dispatchers.computation) + .onEach { eventIdsToDeselect -> selectionState = selectionState.deselect(eventIdsToDeselect) } + .launchIn(this) + combine( timelineController.timelineItems(), room.membersStateFlow, @@ -467,6 +526,7 @@ class TimelinePresenter( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread.value, + selectionState = selectionState, useNewTimelineEventRenderer = useNewTimelineEventRenderer, eventSink = ::handleEvent, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt index 9376747715..dcbbc5e13d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt @@ -22,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlin.time.Duration data class TimelineState( @@ -38,6 +40,7 @@ data class TimelineState( val displayThreadSummaries: Boolean, val displayJumpToUnread: Boolean, val jumpToUnread: JumpToUnreadState, + val selectionState: SelectionState, // When true, formatted message bodies are rendered with the native Compose renderer. val useNewTimelineEventRenderer: Boolean, val eventSink: (TimelineEvent) -> Unit, @@ -104,3 +107,43 @@ sealed interface JumpToUnreadState { /** The read marker event is older than the loaded window — load it via focused-event navigation. */ data class OutOfWindow(val eventId: EventId) : JumpToUnreadState } + +val TimelineState.isSelectionModeActive: Boolean + get() = selectionState is SelectionState.Active + +val TimelineState.selectedCount: Int + get() = (selectionState as? SelectionState.Active)?.selectedEventIds?.size ?: 0 + +fun TimelineState.isSelected(item: TimelineItem): Boolean { + val selection = selectionState as? SelectionState.Active ?: return false + return when (item) { + is TimelineItem.Event -> selection.selectedEventIds.contains(item.eventId) + else -> false + } +} + +/** + * Whether [item] can be selected right now: selection mode must be active, and the action it was entered for must accept this event. + */ +fun TimelineState.canSelect(item: TimelineItem): Boolean { + val selection = selectionState as? SelectionState.Active ?: return false + return when (item) { + is TimelineItem.Event -> selection.action.canApplyTo(item) + else -> false + } +} + +/** + * The selected event ids, sorted from the oldest to the most recent one, as they appear in the timeline. + * The selection itself is stored as a set, in the order the events were tapped, which is not necessarily the order they must be acted on. + */ +fun TimelineState.selectedEventIdsInTimelineOrder(): ImmutableList { + val selectedEventIds = (selectionState as? SelectionState.Active)?.selectedEventIds ?: return persistentListOf() + return timelineItems + .filterIsInstance() + .mapNotNull { it.eventId } + .filter { it in selectedEventIds } + // Most recent item is first in the timeline, so reverse the result. + .reversed() + .toImmutableList() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index af5bca7b2d..5f9f56a910 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -63,6 +63,7 @@ fun aTimelineState( displayJumpToUnread: Boolean = false, jumpToUnread: JumpToUnreadState = JumpToUnreadState.Hidden, newEventState: NewEventState = NewEventState.None, + selectionState: SelectionState = SelectionState.Disabled, useNewTimelineEventRenderer: Boolean = false, eventSink: (TimelineEvent) -> Unit = {}, ): TimelineState { @@ -85,6 +86,7 @@ fun aTimelineState( displayThreadSummaries = displayThreadSummaries, displayJumpToUnread = displayJumpToUnread, jumpToUnread = jumpToUnread, + selectionState = selectionState, useNewTimelineEventRenderer = useNewTimelineEventRenderer, eventSink = eventSink, ) @@ -157,6 +159,8 @@ internal fun aTimelineItemEvent( content: TimelineItemEventContent = aTimelineItemTextContent(), groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, sendState: LocalEventSendState? = null, + editSendState: LocalEventSendState? = null, + redactionSendState: LocalEventSendState? = null, inReplyTo: InReplyToDetails? = null, threadInfo: TimelineItemThreadInfo? = null, debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(), @@ -183,6 +187,8 @@ internal fun aTimelineItemEvent( ), groupPosition = groupPosition, localSendState = sendState, + editSendState = editSendState, + redactionSendState = redactionSendState, inReplyTo = inReplyTo, threadInfo = threadInfo, origin = null, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index ed53a9f885..7d1a51c993 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.timeline import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -63,7 +64,6 @@ import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.rememberNestedScrollInteropConnection @@ -89,11 +89,12 @@ import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPr import io.element.android.features.messages.impl.timeline.focus.FocusRequestStateView import io.element.android.features.messages.impl.timeline.model.NewEventState import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.preview.ElementPreview @@ -110,6 +111,7 @@ import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.wysiwyg.link.Link import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -167,8 +169,6 @@ fun TimelineView( state.eventSink(TimelineEvent.FocusOnEvent(eventId)) } - val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val view = LocalView.current fun inReplyToClick(eventId: EventId) { state.eventSink(TimelineEvent.FocusOnEvent(eventId)) @@ -178,16 +178,25 @@ fun TimelineView( view.performHapticFeedback( HapticFeedbackConstants.LONG_PRESS ) - context.copyToClipboard( - text = link.url, - toastMessage = toastMessage, - ) + state.eventSink(TimelineEvent.CopyToClipboard(link.url)) } fun prefetchMoreItems() { state.eventSink(TimelineEvent.LoadMore(Timeline.PaginationDirection.BACKWARDS)) } + val isSelectionModeActive = state.isSelectionModeActive + // Animate once here (shared by every row) rather than per-row: the value is identical for all + // rows since it only depends on the timeline-wide selection mode. + val selectionProgress by animateFloatAsState( + targetValue = if (isSelectionModeActive) 1f else 0f, + label = "selectionProgress", + ) + fun onToggleSelection(event: TimelineItem.Event) { + val eventId = event.eventId ?: return + state.eventSink(TimelineEvent.ToggleSelection(eventId)) + } + // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms AnimatedVisibility(visible = true, enter = fadeIn()) { val composeLocalTimelineEventRendererConfig = remember(state.useNewTimelineEventRenderer, state.timelineRoomInfo.currentUserId) { @@ -220,12 +229,22 @@ fun TimelineView( isLastOutgoingMessage = state.isLastOutgoingMessage(timelineItem.identifier()), focusedEventId = state.focusedEventId, displayThreadSummaries = state.displayThreadSummaries, + selectionData = TimelineItemSelectionData( + isSelectionModeActive = isSelectionModeActive, + progress = selectionProgress, + isSelected = state.isSelected(timelineItem), + canBeSelected = state.canSelect(timelineItem), + ), onUserDataClick = onUserDataClick, onLinkClick = onLinkClick, onLinkLongClick = ::onLinkLongClick, - onContentClick = onContentClick, + onContentClick = { event -> + if (state.canSelect(event)) onToggleSelection(event) else onContentClick(event) + }, onGalleryItemClick = onGalleryItemClick, - onLongClick = onMessageLongClick, + onLongClick = { event -> + if (state.canSelect(event)) onToggleSelection(event) else onMessageLongClick(event) + }, inReplyToClick = ::inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, @@ -578,8 +597,8 @@ private fun JumpToPositionButton( modifier = Modifier .align(dotAlignment) .offset { IntOffset(x = 0, y = dotYOffset.roundToPx()) }, - color = ElementTheme.colors.iconSuccessPrimary, border = BorderStroke(2.dp, ElementTheme.colors.bgCanvasDefault), + count = 0, ) } } @@ -623,6 +642,36 @@ internal fun TimelineViewPreview( } } +@PreviewsDayNight +@Composable +internal fun TimelineViewSelectionModePreview() = ElementPreview { + val timelineItems = aTimelineItemList(aTimelineItemTextContent()) + val selectedEventId = timelineItems.filterIsInstance().first { SelectionAction.Forward.canApplyTo(it) }.eventId!! + CompositionLocalProvider( + LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(), + ) { + TimelineView( + state = aTimelineState( + timelineItems = timelineItems, + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(selectedEventId)), + ), + timelineProtectionState = aTimelineProtectionState(), + onUserDataClick = {}, + onLinkClick = {}, + onContentClick = {}, + onMessageLongClick = {}, + onSwipeToReply = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onJoinCallClick = {}, + onMoreReactionsClick = {}, + onReadReceiptClick = {}, + onGalleryItemClick = { _, _ -> }, + forceJumpToBottomVisibility = true, + ) + } +} + // The jump-to-unread FAB's indicator is always rendered when the FAB is visible — in // production the FAB only appears when unread content exists above. So `hasUnreadAbove` // here only varies the scroll-target state, not the upper indicator's visibility. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index 0dcf489fe3..7f4375e632 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -32,6 +32,8 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.layer.CompositingStrategy +import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.layout.layout import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -53,12 +55,20 @@ import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.utils.a11y.isTalkbackActive import io.element.android.libraries.ui.utils.graphics.drawInLayer +import kotlin.math.min private val BUBBLE_RADIUS = 12.dp private val avatarRadius = AvatarSize.TimelineSender.dp / 2 private val MIN_BUBBLE_WIDTH = 80.dp +/** + * Alignment line published by [MessageEventBubble] at its vertical center. + * It lets the timeline row align decorations with the bubble itself, instead of with the whole row, whose height also + * covers the sender information, the reactions, the thread summary and the read receipts. + */ +val BubbleVerticalCenter = HorizontalAlignmentLine(::min) + @Composable fun MessageEventBubble( state: BubbleState, @@ -93,6 +103,12 @@ fun MessageEventBubble( val updatedBorderColor by rememberUpdatedState(borderColor) BoxWithConstraints( modifier = modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height, mapOf(BubbleVerticalCenter to placeable.height / 2)) { + placeable.place(0, 0) + } + } .drawWithCache { // Calculate the outline of the background and cache it val outline = bubbleShape.createOutline(size, layoutDirection, this) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt index 931e0de801..7b667c0d6a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessagesReactionButton.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt new file mode 100644 index 0000000000..f405b79e69 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/SelectionIndicator.kt @@ -0,0 +1,48 @@ +/* + * 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.components + +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Checkbox + +/** + * Selection indicator used in the timeline selection mode. It renders the Compound [Checkbox], with the minimum + * interactive size enforcement disabled so that it fits the compact selection column. It is purely visual: the tap + * is handled by the whole-row click target, so this only reflects [selected]. + */ +@Composable +internal fun SelectionIndicator( + selected: Boolean, + modifier: Modifier = Modifier, +) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Checkbox( + modifier = modifier, + checked = selected, + onCheckedChange = null, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun SelectionIndicatorPreview() = ElementPreview { + SelectionIndicator(selected = false) +} + +@PreviewsDayNight +@Composable +internal fun SelectionIndicatorSelectedPreview() = ElementPreview { + SelectionIndicator(selected = true) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index d7492ca40b..7bc5cbf059 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -55,13 +55,20 @@ fun TimelineEventTimestampView( val shield = event.messageShield val isVerifiedUserSendFailure = event.localSendState is LocalEventSendState.Failed.VerifiedUser val onClickLabel = when { + hasError -> stringResource(CommonStrings.a11y_view_details) shield != null -> stringResource(CommonStrings.a11y_view_details) isVerifiedUserSendFailure -> stringResource(CommonStrings.action_open_context_menu) - hasError -> stringResource(CommonStrings.a11y_view_details) else -> null } val clickableModifier = remember(event) { when { + hasError -> { + Modifier.clickable( + onClickLabel = onClickLabel, + ) { + eventSink(TimelineEvent.ShowSendFailureDialog(event)) + } + } shield != null -> { Modifier.clickable( onClickLabel = onClickLabel, @@ -69,12 +76,6 @@ fun TimelineEventTimestampView( eventSink(TimelineEvent.ShowShieldDialog(shield)) } } - hasError -> Modifier - .clickable( - onClickLabel = onClickLabel, - ) { - eventSink(TimelineEvent.ShowSendFailureDialog(event)) - } else -> Modifier } } 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 97ec71173a..c74660f03d 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -59,6 +60,7 @@ import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection @@ -180,6 +182,11 @@ fun TimelineItemEventRow( onSwipeToReply: () -> Unit, eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, modifier: Modifier = Modifier, + // Horizontal offset by which selection mode slides this row's content aside, to make room for the tick. It is applied here, to the message and the + // thread summary, rather than to the row as a whole: that keeps the row's accessibility node spanning the whole row, tick included, so that a + // screen reader can focus it from anywhere on the row. The read receipts are anchored to the end edge and stay pinned, so they are not slid. + selectionContentOffset: Dp = 0.dp, + isSelectionToggle: Boolean = false, eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange -> // Only pass down a custom clickable lambda if the content can be clicked separately val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable } @@ -221,6 +228,7 @@ fun TimelineItemEventRow( val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo val accessibilityActions = rememberTimelineItemAccessibilityActions( + isEnabled = !isSelectionToggle, canReply = canReply, onLongClick = onLongClick, onSwipeToReply = onSwipeToReply, @@ -242,7 +250,11 @@ fun TimelineItemEventRow( val swipeThresholdPx = 40.dp.toPx() val thresholdCrossed = abs(offset) > swipeThresholdPx SwipeSensitivity(3f) { - Box(Modifier.fillMaxWidth()) { + Box( + Modifier + .fillMaxWidth() + .offset(x = selectionContentOffset) + ) { Row(modifier = Modifier.matchParentSize()) { ReplySwipeIndicator({ offset / 120 }) } @@ -263,7 +275,7 @@ fun TimelineItemEventRow( .absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) } .draggable( orientation = Orientation.Horizontal, - enabled = !state.isResettingOnRelease, + enabled = !state.isResettingOnRelease && !isSelectionToggle, onDragStopped = { coroutineScope.launch { if (thresholdCrossed) { @@ -281,6 +293,7 @@ fun TimelineItemEventRow( } } else { TimelineItemEventRowContent( + modifier = Modifier.offset(x = selectionContentOffset), event = event, timelineMode = timelineMode, timelineProtectionState = timelineProtectionState, @@ -306,7 +319,10 @@ fun TimelineItemEventRow( .padding(end = 16.dp) } else { if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp) - }.padding(top = 2.dp), + } + .padding(top = 2.dp) + .offset(x = selectionContentOffset) + .hiddenFromAccessibilityWhile(isSelectionToggle), threadSummary = event.threadInfo.summary, latestEventText = event.threadInfo.latestEventText, isOutgoing = event.isMine, @@ -319,24 +335,41 @@ fun TimelineItemEventRow( } // Read receipts / Send state + val sendState = event.pendingSend()?.second TimelineItemReadReceiptView( state = ReadReceiptViewState( - sendState = event.localSendState, + sendState = sendState, isLastOutgoingMessage = isLastOutgoingMessage, receipts = event.readReceiptState.receipts, ), onReadReceiptsClick = { onReadReceiptClick(event) }, - modifier = Modifier.padding(top = 4.dp) + // Counter the selection-mode content slide so the end-anchored read receipts stay pinned. + modifier = Modifier + .padding(top = 4.dp) + .hiddenFromAccessibilityWhile(isSelectionToggle) ) } } +/** + * Hides this element and its descendants from screen readers while [hidden] is true. + * + * A clickable descendant stays an accessibility node of its own even though the row merges its descendants, so while the row acts as a selection toggle + * it would otherwise still be reachable and activatable by a screen reader. Reactions already hide themselves this way at all times. + */ +private fun Modifier.hiddenFromAccessibilityWhile(hidden: Boolean): Modifier = if (hidden) { + semantics { hideFromAccessibility() } +} else { + this +} + /** * Exposes the gestures of a timeline item — long press for the action list, swipe for a reply — as TalkBack actions, since neither gesture is reachable * with a screen reader enabled. */ @Composable private fun rememberTimelineItemAccessibilityActions( + isEnabled: Boolean, canReply: Boolean, onLongClick: () -> Unit, onSwipeToReply: () -> Unit, @@ -345,8 +378,9 @@ private fun rememberTimelineItemAccessibilityActions( val replyLabel = stringResource(CommonStrings.action_reply) val latestOnLongClick by rememberUpdatedState(onLongClick) val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply) - return remember(canReply, messageActionsLabel, replyLabel) { + return remember(isEnabled, canReply, messageActionsLabel, replyLabel) { buildList { + if (!isEnabled) return@buildList add( CustomAccessibilityAction(messageActionsLabel) { latestOnLongClick() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 0128e80b79..b03b76dd79 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.receipt.Rea import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView import io.element.android.features.messages.impl.timeline.groups.isRedactedMessagesGroup import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState import io.element.android.libraries.designsystem.preview.ElementPreview @@ -190,6 +191,7 @@ private fun TimelineItemGroupedEventsRowContent( onSwipeToReply = {}, eventSink = eventSink, eventContentView = eventContentView, + selectionData = TimelineItemSelectionData(), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index aa66c262b5..9dbcb4851d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -8,28 +8,41 @@ package io.element.android.features.messages.impl.timeline.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineRoomInfo import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemSelectionData import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent @@ -60,6 +73,7 @@ internal fun TimelineItemRow( timelineProtectionState: TimelineProtectionState, focusedEventId: EventId?, displayThreadSummaries: Boolean, + selectionData: TimelineItemSelectionData, onUserDataClick: (MatrixUser) -> Unit, onLinkClick: (Link) -> Unit, onLinkLongClick: (Link) -> Unit, @@ -92,17 +106,133 @@ internal fun TimelineItemRow( ) }, ) { - val backgroundModifier = if (timelineItem.isEvent(focusedEventId)) { - val focusedEventOffset = if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { - 14.dp - } else { - 2.dp + val isSelectionModeActive = selectionData.isSelectionModeActive + val selectionProgress = selectionData.progress + val isSelected = selectionData.isSelected + val backgroundModifier = when { + timelineItem.isEvent(focusedEventId) && !isSelectionModeActive -> { + val focusedEventOffset = + if ((timelineItem as? TimelineItem.Event)?.showSenderInformation == true) { + 14.dp + } else { + 2.dp + } + Modifier.focusedEvent(focusedEventOffset) + } + else -> { + Modifier } - Modifier.focusedEvent(focusedEventOffset) - } else { - Modifier } - Box(modifier = modifier.then(backgroundModifier)) { + val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected } + val layoutDirection = LocalLayoutDirection.current + Layout( + modifier = modifier.then(backgroundModifier), + contents = listOf( + { + TimelineItemRowContent( + modifier = Modifier.fillMaxWidth(), + selectionData = selectionData, + timelineItem = timelineItem, + timelineMode = timelineMode, + timelineRoomInfo = timelineRoomInfo, + timelineProtectionState = timelineProtectionState, + isLastOutgoingMessage = isLastOutgoingMessage, + focusedEventId = focusedEventId, + displayThreadSummaries = displayThreadSummaries, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onLinkLongClick = onLinkLongClick, + onContentClick = onContentClick, + onGalleryItemClick = onGalleryItemClick, + onLongClick = onLongClick, + inReplyToClick = inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, + onReadReceiptClick = onReadReceiptClick, + onJoinCallClick = onJoinCallClick, + onSwipeToReply = onSwipeToReply, + eventSink = eventSink, + eventContentView = eventContentView, + ) + }, + { + if (selectableEvent != null && selectionProgress > 0f) { + val slideSign = if (layoutDirection == LayoutDirection.Ltr) -1f else 1f + SelectionIndicator( + selected = isSelected, + modifier = Modifier + .graphicsLayer { translationX = slideSign * SELECTION_COLUMN_WIDTH.toPx() * (1f - selectionProgress) } + .padding(start = 16.dp) + .size(24.dp), + ) + } + }, + { + if (isSelectionModeActive) { + // In selection mode the whole row toggles selection. + Box( + modifier = Modifier + .clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) { + if (selectableEvent != null) { + onContentClick(selectableEvent) + } + } + .semantics { hideFromAccessibility() } + ) + } + }, + ), + ) { (contentMeasurables, indicatorMeasurables, overlayMeasurables), constraints -> + val contentPlaceable = contentMeasurables.first().measure(constraints) + val width = contentPlaceable.width + val height = contentPlaceable.height + val indicatorPlaceable = indicatorMeasurables.firstOrNull()?.measure(constraints.copy(minWidth = 0, minHeight = 0)) + val overlayPlaceable = overlayMeasurables.firstOrNull()?.measure(Constraints.fixed(width, height)) + layout(width = width, height = height) { + contentPlaceable.place(0, 0) + if (indicatorPlaceable != null) { + val bubbleCenter = contentPlaceable[BubbleVerticalCenter].takeIf { it != AlignmentLine.Unspecified } ?: height / 2 + val x = if (layoutDirection == LayoutDirection.Ltr) 0 else width - indicatorPlaceable.width + indicatorPlaceable.place(x = x, y = bubbleCenter - indicatorPlaceable.height / 2) + } + overlayPlaceable?.place(0, 0) + } + } +} + +/** Width reserved for the selection indicator : the row content slides by this amount. */ +private val SELECTION_COLUMN_WIDTH = 36.dp + +@Suppress("LongMethod") +@Composable +private fun TimelineItemRowContent( + timelineItem: TimelineItem, + timelineMode: Timeline.Mode, + timelineRoomInfo: TimelineRoomInfo, + isLastOutgoingMessage: Boolean, + timelineProtectionState: TimelineProtectionState, + focusedEventId: EventId?, + displayThreadSummaries: Boolean, + onUserDataClick: (MatrixUser) -> Unit, + onLinkClick: (Link) -> Unit, + onLinkLongClick: (Link) -> Unit, + onContentClick: (TimelineItem.Event) -> Unit, + onGalleryItemClick: (TimelineItem.Event, Int) -> Unit, + onLongClick: (TimelineItem.Event) -> Unit, + inReplyToClick: (EventId) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, + onReadReceiptClick: (TimelineItem.Event) -> Unit, + onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onSwipeToReply: (TimelineItem.Event) -> Unit, + eventSink: (TimelineEvent.TimelineItemEvent) -> Unit, + modifier: Modifier = Modifier, + selectionData: TimelineItemSelectionData = TimelineItemSelectionData(), + eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit, +) { + Box(modifier) { when (timelineItem) { is TimelineItem.Virtual -> { TimelineItemVirtualRow( @@ -149,6 +279,10 @@ internal fun TimelineItemRow( } else -> { val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message) + val isSelectionToggle = selectionData.isSelectionModeActive && selectionData.canBeSelected + // Only start-aligned message bubbles slide to make room for the selection indicator. Outgoing (own) messages are end-aligned, + // opposite the indicator, so they already have room, and the centered tiles handled by the branches above never slide. + val contentSlide = if (timelineItem.isMine) 0.dp else SELECTION_COLUMN_WIDTH * selectionData.progress TimelineItemEventRow( modifier = Modifier .semantics(mergeDescendants = true) { @@ -158,6 +292,9 @@ internal fun TimelineItemRow( } else { timelineItem.safeSenderName } + if (isSelectionToggle) { + toggleableState = ToggleableState(selectionData.isSelected) + } // For Polls, allow the answers to be traversed by Talkback isTraversalGroup = timelineItem.content is TimelineItemPollContent || timelineItem.failedToSend || @@ -166,16 +303,18 @@ internal fun TimelineItemRow( } // Custom clickable that applies over the whole item for accessibility .then( - if (isTalkbackActive()) { - Modifier + when { + !isTalkbackActive() -> Modifier + // While selecting, the row only toggles the selection. The context menu cannot be opened, so the long press + // that opens it is neither offered nor advertised. + isSelectionToggle -> Modifier.clickable { onContentClick(timelineItem) } + else -> Modifier .combinedClickable( onClick = { onContentClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onLongClickLabel = stringResource(CommonStrings.action_open_context_menu), ) .onKeyboardContextMenuAction { onLongClick(timelineItem) } - } else { - Modifier } ), event = timelineItem, @@ -197,6 +336,8 @@ internal fun TimelineItemRow( onSwipeToReply = { onSwipeToReply(timelineItem) }, onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) }, eventSink = eventSink, + selectionContentOffset = contentSlide, + isSelectionToggle = isSelectionToggle, eventContentView = { contentModifier, onContentLayoutChange -> eventContentView(timelineItem, contentModifier, onContentLayoutChange) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt index 232c107698..2e71af8447 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAttachmentsListView.kt @@ -48,13 +48,13 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState 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 import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState import io.element.android.libraries.matrix.ui.media.contentvalidation.rememberEventContentValidationState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings import kotlin.math.max diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt index c7878c00f5..aef5952689 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemStickerView.kt @@ -37,10 +37,10 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings private const val STICKER_SIZE_IN_DP = 128 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 8a4f2635a1..cf8c9cb416 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.libraries.htmlrenderer.api.ui.HtmlMessageContent import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanUpdater +import io.element.android.libraries.textcomposer.mentions.limitMentionPillWidth import io.element.android.libraries.ui.common.layout.ContentAvoidingLayout import io.element.android.libraries.ui.common.layout.ContentAvoidingLayoutData import io.element.android.wysiwyg.compose.EditorStyledText @@ -93,7 +94,11 @@ fun TimelineItemTextView( LocalTextStyle provides textStyle ) { val text = getTextWithResolvedMentions(content) - Box(modifier.semantics { contentDescription = content.plainText }) { + Box( + modifier + .semantics { contentDescription = content.plainText } + .limitMentionPillWidth(text) + ) { EditorStyledText( text = text, onLinkClickedListener = onLinkClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 5b3803713e..a4dede5d37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -47,12 +47,12 @@ import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenu import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.collectOverallState +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.a11y.isTalkbackActive diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt index 5b6c7fda37..17e650a9e9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryView.kt @@ -71,8 +71,8 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.coroutines.launch internal val REACTION_SUMMARY_LINE_HEIGHT = 25.sp diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt index 751a88cb39..0810a41dce 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/ReadReceiptViewStatePreviewParam.kt @@ -20,9 +20,9 @@ import kotlinx.collections.immutable.toImmutableList class ReadReceiptViewStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aReadReceiptViewState(), - aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event), - aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId"))), + aReadReceiptViewState(sendState = null, isLastOutgoingMessage = true), + aReadReceiptViewState(sendState = LocalEventSendState.Sending.Event, isLastOutgoingMessage = true), + aReadReceiptViewState(sendState = LocalEventSendState.Sent(EventId("\$eventId")), isLastOutgoingMessage = true), aReadReceiptViewState( sendState = LocalEventSendState.Sent(EventId("\$eventId")), receipts = List(1) { aReadReceiptData(it) }, @@ -43,12 +43,15 @@ class ReadReceiptViewStatePreviewParam : PreviewParameterProvider = emptyList(), ) = ReadReceiptViewState( sendState = sendState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 17a9b0c5eb..30b4ee2d42 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -57,43 +57,60 @@ fun TimelineItemReadReceiptView( onReadReceiptsClick: () -> Unit, modifier: Modifier = Modifier, ) { - if (state.receipts.isNotEmpty()) { - ReadReceiptsRow(modifier = modifier) { - ReadReceiptsAvatars( - receipts = state.receipts, - onClick = onReadReceiptsClick, - ) + val content: @Composable (() -> Unit)? = when (state.sendState) { + // If null, assume it's Sent and act the same way + null if state.isLastOutgoingMessage && state.receipts.isEmpty() -> { + @Composable { SentIcon() } } - } else { - when (state.sendState) { - is LocalEventSendState.Sending -> { - ReadReceiptsRow(modifier) { - Icon( - modifier = Modifier.padding(2.dp), - imageVector = CompoundIcons.Circle(), - contentDescription = stringResource(id = CommonStrings.common_sending), - tint = ElementTheme.colors.iconSecondary - ) - } - } - is LocalEventSendState.Failed -> { - // Error? The timestamp is already displayed in red - } - null, - is LocalEventSendState.Sent -> { - if (state.isLastOutgoingMessage) { - ReadReceiptsRow(modifier = modifier) { - Icon( - modifier = Modifier.padding(2.dp), - imageVector = CompoundIcons.CheckCircle(), - contentDescription = stringResource(id = CommonStrings.common_sent), - tint = ElementTheme.colors.iconSecondary - ) - } - } + is LocalEventSendState.Sending -> { + @Composable { SendingIcon() } + } + is LocalEventSendState.Failed -> { + // Error? The timestamp is already displayed in red + null + } + is LocalEventSendState.Sent if state.isLastOutgoingMessage && state.receipts.isEmpty() -> { + @Composable { SentIcon() } + } + else -> if (state.receipts.isNotEmpty()) { + @Composable { + ReadReceiptsAvatars( + receipts = state.receipts, + onClick = onReadReceiptsClick, + ) } + } else { + null } } + + // Only show the row if there is content to display + content?.let { + ReadReceiptsRow( + modifier = modifier, + content = it, + ) + } +} + +@Composable +private fun SendingIcon() { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.Circle(), + contentDescription = stringResource(id = CommonStrings.common_sending), + tint = ElementTheme.colors.iconSecondary + ) +} + +@Composable +private fun SentIcon() { + Icon( + modifier = Modifier.padding(2.dp), + imageVector = CompoundIcons.CheckCircle(), + contentDescription = stringResource(id = CommonStrings.common_sent), + tint = ElementTheme.colors.iconSecondary + ) } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt index 86c2beb707..9577ba297a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetStatePreviewParam.kt @@ -19,7 +19,7 @@ class ReadReceiptBottomSheetStatePreviewParam : PreviewParameterProvider = readReceiptViewStateProvider.values - .filter { it.sendState is LocalEventSendState.Sent } + .filter { it.sendState is LocalEventSendState.Sent && it.receipts.isNotEmpty() } .map { readReceiptViewState -> ReadReceiptBottomSheetState( selectedEvent = aTimelineItemEvent( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt index 6288f3f159..3c80cea7f7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo +import io.element.android.libraries.ui.strings.CommonStrings @ContributesNode(RoomScope::class) @AssistedInject class EventDebugInfoNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Node(buildContext, plugins = plugins) { data class Inputs( val eventId: EventId?, @@ -39,6 +44,12 @@ class EventDebugInfoNode( navigateUp() } + private fun onCopyText(text: String) { + clipboardHelper.copyPlainText(text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } + @Composable override fun View(modifier: Modifier) = with(inputs) { EventDebugInfoView( @@ -46,7 +57,8 @@ class EventDebugInfoNode( model = timelineItemDebugInfo.model, originalJson = timelineItemDebugInfo.originalJson, latestEditedJson = timelineItemDebugInfo.latestEditedJson, - onBackClick = ::onBackClick + onBackClick = ::onBackClick, + onCopyText = ::onCopyText, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt index 3b0448ddb3..f836bc5d68 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt @@ -8,8 +8,6 @@ package io.element.android.features.messages.impl.timeline.debug -import android.content.ClipData -import android.content.ClipboardManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically @@ -24,7 +22,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,11 +32,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.core.content.getSystemService import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.button.BackButton @@ -57,7 +52,6 @@ import org.json.JSONObject * Screen used to display debug info for events. * It will only be available in debug builds. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun EventDebugInfoView( eventId: EventId?, @@ -65,6 +59,7 @@ fun EventDebugInfoView( originalJson: String?, latestEditedJson: String?, onBackClick: () -> Unit, + onCopyText: (String) -> Unit, modifier: Modifier = Modifier, isTest: Boolean = false, ) { @@ -89,20 +84,30 @@ fun EventDebugInfoView( item { Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(text = "Event ID:") - CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth()) + CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth()) } } item { - CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded) + CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded) } if (originalJson != null) { item { - CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded) + CollapsibleSection( + title = "Original JSON:", + text = prettyJSON(originalJson), + onCopyText = onCopyText, + initiallyExpanded = sectionsInitiallyExpanded, + ) } } if (latestEditedJson != null) { item { - CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded) + CollapsibleSection( + title = "Latest edited JSON:", + text = prettyJSON(latestEditedJson), + onCopyText = onCopyText, + initiallyExpanded = sectionsInitiallyExpanded, + ) } } } @@ -122,6 +127,7 @@ private fun prettyJSON(maybeJSON: String): String { private fun CollapsibleSection( title: String, text: String, + onCopyText: (String) -> Unit, initiallyExpanded: Boolean = false, ) { var isExpanded by remember { mutableStateOf(initiallyExpanded) } @@ -141,7 +147,7 @@ private fun CollapsibleSection( ) } AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { - CopyableText(text = text, modifier = Modifier.fillMaxWidth()) + CopyableText(text = text, onClick = onCopyText, modifier = Modifier.fillMaxWidth()) } } } @@ -149,16 +155,15 @@ private fun CollapsibleSection( @Composable private fun CopyableText( text: String, + onClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val clipboardManager = remember { requireNotNull(context.getSystemService()) } Box( modifier .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(6.dp) - .clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) } + .clickable { onClick(text) } ) { Text( text = text, @@ -176,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview { model = "Rust(\n\tModel()\n)", originalJson = "{\"name\": \"original\"}", latestEditedJson = "{\"name\": \"edited\"}", - onBackClick = { } + onBackClick = { }, + onCopyText = { }, ) } 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 6cd6b66cf1..b7b072880e 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 @@ -123,6 +123,8 @@ class TimelineItemEventFactory( reactionsState = currentTimelineItem.computeReactionsState(), readReceiptState = currentTimelineItem.computeReadReceiptState(roomMembers, renderReadReceipts), localSendState = currentTimelineItem.event.localSendState, + editSendState = currentTimelineItem.event.editSendState, + redactionSendState = currentTimelineItem.event.redactionSendState, inReplyTo = currentTimelineItem.event.inReplyTo()?.map(permalinkParser = permalinkParser), threadInfo = mappedThreadInfo, origin = currentTimelineItem.event.origin, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt index 1cbc2ec29c..8a108b31f9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItem.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @@ -92,6 +93,8 @@ sealed interface TimelineItem { val reactionsState: TimelineItemReactions, val readReceiptState: TimelineItemReadReceipts, val localSendState: LocalEventSendState?, + private val editSendState: LocalEventSendState?, + private val redactionSendState: LocalEventSendState?, val inReplyTo: InReplyToDetails?, val threadInfo: TimelineItemThreadInfo?, val origin: TimelineItemEventOrigin?, @@ -110,7 +113,7 @@ sealed interface TimelineItem { val safeSenderName: String = senderProfile.getDisambiguatedDisplayName(senderId) - val failedToSend: Boolean = localSendState is LocalEventSendState.Failed + val failedToSend: Boolean = listOf(localSendState, editSendState, redactionSendState).any { it is LocalEventSendState.Failed } val isTextMessage: Boolean = content is TimelineItemTextBasedContent @@ -143,6 +146,18 @@ sealed interface TimelineItem { get() = timelineItemDebugInfoProvider() val sendhandle: SendHandle? get() = sendHandleProvider() + + /** + * Returns the first pending send state, if any, along with the corresponding [SendTarget]. + */ + fun pendingSend(): Pair? { + return when { + localSendState != null -> SendTarget.Event to localSendState + editSendState != null -> SendTarget.Edit to editSendState + redactionSendState != null -> SendTarget.Redaction to redactionSendState + else -> null + } + } } data class GroupedEvents( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt new file mode 100644 index 0000000000..be2c39c6bf --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/TimelineItemSelectionData.kt @@ -0,0 +1,28 @@ +/* + * 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.model + +/** + * Row-scoped selection state passed to [io.element.android.features.messages.impl.timeline.components.TimelineItemRow]. + * + * @param isSelectionModeActive whether the timeline is currently in selection mode. Drives + * behaviour (e.g. the whole-row click target) and must reflect the real mode, not [progress]. + * @param isSelected whether this specific row's event is part of the current selection. + * @param canBeSelected whether this specific row's event can be selected for the action selection mode was entered for. + * Rows which cannot be selected render no indicator and ignore clicks, but their content still slides: sliding only depends on the row being a + * start-aligned bubble, so that the bubbles stay aligned with each other. + * @param progress animated 0f..1f value driving the visuals (content slide + indicator slide-in). + * Animated once in TimelineView and shared by every row. Differs from [isSelectionModeActive] + * during the enter/exit animation. + */ +data class TimelineItemSelectionData( + val isSelectionModeActive: Boolean = false, + val isSelected: Boolean = false, + val canBeSelected: Boolean = false, + val progress: Float = 0f, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt index 840f19488d..cfb702678c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemGalleryContent.kt @@ -8,7 +8,7 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration @@ -51,8 +51,8 @@ data class GalleryItem( MediaRequestData( source = thumbnailSource ?: mediaSource, kind = MediaRequestData.Kind.Thumbnail( - width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH, - height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT, + width = thumbnailWidth?.toLong() ?: io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH, + height = thumbnailHeight?.toLong() ?: io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT, ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt index 5e78adc945..e67738b0fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemImageContent.kt @@ -10,9 +10,9 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_HEIGHT -import io.element.android.libraries.matrix.ui.media.MAX_THUMBNAIL_WIDTH -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_HEIGHT +import io.element.android.libraries.matrixmedia.api.MAX_THUMBNAIL_WIDTH +import io.element.android.libraries.matrixmedia.api.MediaRequestData data class TimelineItemImageContent( override val filename: String, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt index 15ee42d575..02f720bed2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/sendfailure/SendFailureDialogView.kt @@ -50,16 +50,18 @@ fun SendFailureDialogView( ) } + val (sendTarget, _) = sendFailureDialogState.event.pendingSend() ?: null to null + ConfirmationDialog( modifier = modifier, title = stringResource(id = CommonStrings.common_sending_failed), content = content, onDismiss = onDismiss, submitText = stringResource(id = CommonStrings.action_retry), - onSubmitClick = { onRetry(sendFailureDialogState.event) }, + onSubmitClick = { sendTarget?.let { onRetry(sendFailureDialogState.event) } }, onCancelClick = onDismiss, thirdButtonText = stringResource(id = CommonStrings.action_remove_message), - onThirdButtonClick = { onRemoveMessage(sendFailureDialogState.event) }, + onThirdButtonClick = { sendTarget?.let { onRemoveMessage(sendFailureDialogState.event) } }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index d6a55c0563..5eed146e26 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -61,7 +60,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MessagesViewTopBar( roomName: String?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt new file mode 100644 index 0000000000..b5e79b84d7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/SelectionModeTopBar.kt @@ -0,0 +1,91 @@ +/* + * 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.topbars + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon +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.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectionModeTopBar( + selectedCount: Int, + action: SelectionAction, + onCancelClick: () -> Unit, + onConfirmClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val a11ySelectedCount = pluralStringResource(CommonPlurals.screen_room_selection_count, selectedCount, selectedCount) + TopAppBar( + modifier = modifier, + title = { + Text( + text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount), + modifier = Modifier.semantics { + heading() + liveRegion = LiveRegionMode.Polite + contentDescription = a11ySelectedCount + }, + ) + }, + navigationIcon = { + IconButton( + onClick = onCancelClick, + ) { + Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close)) + } + }, + actions = { + IconButton( + onClick = onConfirmClick, + enabled = selectedCount > 0, + ) { + Icon( + resourceId = action.iconRes, + contentDescription = stringResource(action.titleRes), + ) + } + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun SelectionModeTopBarPreview( + @PreviewParameter(SelectionCountPreviewParam::class) count: Int, +) = ElementPreview { + SelectionModeTopBar( + selectedCount = count, + action = SelectionAction.Forward, + onCancelClick = {}, + onConfirmClick = {}, + ) +} + +internal class SelectionCountPreviewParam : PreviewParameterProvider { + override val values = sequenceOf(0, 1, 5) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt index 5ef4541f06..c2d08c8c1f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/ThreadTopBar.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -43,7 +42,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ThreadTopBar( roomName: String?, diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml index 6c39d67583..86add66f6e 100644 --- a/features/messages/impl/src/main/res/values-cs/translations.xml +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -7,6 +7,19 @@ "Šifrováno neznámým nebo smazaným zařízením." "Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem." "Šifrováno neověřeným uživatelem." + "Otočit obrázek vodorovně" + "Otočeno vodorovně" + "Originál" + "Otočit obrázek svisle" + "Otočeno svisle " + "Originál" + "Otočit obrázek doleva" + + "%1$d stupeň" + "%1$d stupně" + "%1$d stupňů" + + "Upravit fotku" "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." "Klepnutím změníte kvalitu nahrávání videa" "Soubor nelze nahrát." @@ -17,9 +30,10 @@ "Položka %1$d z %2$d" "Optimalizace kvality obrazu" "Probíhá zpracování…" + "Přidat média" "Zablokovat uživatele" "Zaškrtněte, pokud chcete skrýt všechny aktuální a budoucí zprávy od tohoto uživatele" - "Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy." + "Tato zpráva bude nahlášena vašemu poskytovateli účtu. Nebude moci číst žádné šifrované zprávy." "Důvod nahlášení tohoto obsahu" "Fotoaparát" "Vyfotit" diff --git a/features/messages/impl/src/main/res/values-el/translations.xml b/features/messages/impl/src/main/res/values-el/translations.xml index 8a4e935b9e..72c752ba66 100644 --- a/features/messages/impl/src/main/res/values-el/translations.xml +++ b/features/messages/impl/src/main/res/values-el/translations.xml @@ -7,6 +7,18 @@ "Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή." "Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της." "Κρυπτογραφημένο από μη επαληθευμένο χρήστη." + "Αναστροφή εικόνας οριζόντια" + "Αναποδογυρισμένο οριζόντια" + "Πρωτότυπο" + "Αναστροφή εικόνας κάθετα" + "Αναποδογυρισμένο κάθετα" + "Πρωτότυπο" + "Περιστρέψτε την εικόνα προς τα αριστερά" + + "%1$d μοίρα" + "%1$d μοίρες" + + "Επεξεργασία φωτογραφίας" "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." "Πατήστε για να αλλάξετε την ποιότητα μεταφόρτωσης βίντεο" "Δεν ήταν δυνατή η μεταφόρτωση του αρχείου." @@ -17,6 +29,7 @@ "Στοιχείο %1$d από %2$d" "Βελτιστοποίηση ποιότητας εικόνας" "Επεξεργασία…" + "Προσθήκη πολυμέσων" "Αποκλεισμός χρήστη" "Επέλεξε εάν θες να αποκρύψεις όλα τα τρέχοντα και μελλοντικά μηνύματα από αυτόν τον χρήστη" "Αυτό το μήνυμα θα αναφερθεί στον πάροχο του λογαριασμού σας. Δεν θα μπορεί να διαβάσει κανένα κρυπτογραφημένο μήνυμα." @@ -57,6 +70,10 @@ "Εμφάνιση περισσότερων" "Εμφάνιση περίληψης αντιδράσεων" "Νέο" + + "%1$d μήνυμα αφαιρέθηκε" + "%1$d μηνύματα αφαιρέθηκαν" + "%1$d αλλαγή αίθουσας" "%1$d αλλαγές αίθουσας" diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index b02b08dad5..435e279b7c 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -47,6 +47,10 @@ "Partager votre position" "Sondage" "Formatage du texte" + "Raison (facultatif)" + "Saisissez la raison…" + "(Facultatif)" + "Supprimer le message ?" "L’historique des messages n’est actuellement pas disponible dans ce salon" "L’historique de la discussion n’est pas disponible. Vérifiez cette session pour accéder à l’historique." "Souhaitez-vous inviter l’ancien membre à revenir ?" diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml index 33daf3b00f..8b8a43cde7 100644 --- a/features/messages/impl/src/main/res/values-hr/translations.xml +++ b/features/messages/impl/src/main/res/values-hr/translations.xml @@ -48,9 +48,9 @@ "Anketa" "Oblikovanje teksta" "Razlog (neobavezno)" - "Unesite…" + "Unesite razlog…" "(Neobavezno)" - "Potvrdite uklanjanje" + "Ukloniti poruku?" "Povijest poruka trenutačno nije dostupna." "Povijest poruka nije dostupna u ovoj sobi. Potvrdite ovaj uređaj kako biste vidjeli povijest poruka." "Želite li ih pozvati natrag?" 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 4befd393b6..037c1d1374 100644 --- a/features/messages/impl/src/main/res/values-hu/translations.xml +++ b/features/messages/impl/src/main/res/values-hu/translations.xml @@ -48,9 +48,9 @@ "Szavazás" "Szövegformázás" "Ok (nem kötelező)" - "Írja be…" + "Indok beírása…" "(Választható)" - "Eltávolítás megerősítése" + "Üzenet törlése?" "Az üzenetelőzmények jelenleg nem érhetők el." "Az üzenetelőzmények nem érhetők el ebben a szobában. Ellenőrizze ezt az eszközt, hogy lássa az előzményeket." "Visszahívja?" diff --git a/features/messages/impl/src/main/res/values-in/translations.xml b/features/messages/impl/src/main/res/values-in/translations.xml index bae097e818..bed52fe550 100644 --- a/features/messages/impl/src/main/res/values-in/translations.xml +++ b/features/messages/impl/src/main/res/values-in/translations.xml @@ -36,7 +36,7 @@ "Tambahkan media" "Blokir pengguna" "Centang jika Anda ingin menyembunyikan semua pesan saat ini dan yang akan datang dari pengguna ini" - "Pesan ini akan dilaporkan ke administrator homeserver Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." + "Pesan ini akan dilaporkan ke penyedia akun Anda. Mereka tidak akan dapat membaca pesan terenkripsi apa pun." "Alasan melaporkan konten ini" "Kamera" "Ambil foto" 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 987071163c..150922e999 100644 --- a/features/messages/impl/src/main/res/values-pl/translations.xml +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -49,9 +49,9 @@ "Ankieta" "Formatowanie tekstu" "Powód (opcjonalne)" - "Powód…" + "Wprowadź powód…" "(Opcjonalne)" - "Potwierdź usunięcie" + "Usunąć wiadomość?" "Historia wiadomości jest obecnie niedostępna." "Historia wiadomości jest niedostępna w tym pokoju. Zweryfikuj to urządzenie, aby zobaczyć historię wiadomości." "Czy chcesz zaprosić ich z powrotem?" 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 d26a35bfc2..e0d789d937 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -3,10 +3,15 @@ "Expeditorul evenimentului nu corespunde proprietarului dispozitivului care l-a trimis." "Autenticitatea acestui mesaj criptat nu poate fi garantată pe acest dispozitiv." "Criptat de un utilizator verificat anterior." - "Necriptat" + "Necriptat." "Criptat de un dispozitiv necunoscut sau șters." "Criptat de un dispozitiv care nu este verificat de proprietarul său." "Criptat de un utilizator neverificat." + "Tip MIME nevalid: %s" + "Conținut media lipsă" + "Detaliile erorii: %s" + "Trimitere de pe un dispozitiv neverificat" + "Eroare necunoscută" "Răsturnați imaginea pe orizontală" "Răsturnat pe orizontală" "Original" @@ -17,14 +22,14 @@ "%1$d grad" "%1$d grade" - "%1$d grade" + "%1$d de 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." - "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." - "Încărcarea fișierelor media a eșuat, încercați din nou." + "Nu s-au putut procesa fișierele media, încercați din nou." + "Nu s-au putut încărca fișierele media, încercați din nou." "Dimensiunea maximă permisă pentru fișiere este de %1$s." "Fișierul este prea mare pentru a fi încărcat." "Elementul %1$d din %2$d" @@ -32,8 +37,8 @@ "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." + "Bifați dacă doriți să ascundeți toate mesajele actuale și viitoare de la acest utilizator" + "Acest mesaj va fi raportat furnizorului contului dvs. Acesta nu va putea citi niciun mesaj criptat." "Motivul raportării acestui conținut" "Cameră foto" "Faceți o fotografie" @@ -43,30 +48,34 @@ "Partajați locația" "Sondaj" "Formatarea textului" - "Mesajele anterioare nu sunt momentan disponibile în această cameră" + "Motiv (opțional)" + "Introduceți motivul…" + "(Opțional)" + "Doriți să ștergeți mesajul?" + "Mesajele anterioare nu sunt disponibile momentan." "Mesajele anterioare nu sunt disponibile în această cameră. Verificați acest dispozitiv pentru a vedea mesajele anterioare." "Doriți să îi invitați înapoi?" - "Sunteți singur în această cameră" + "Sunteți singura persoană din această conversație" "Notificați întreaga cameră" "Toți" "Trimiteți din nou" "Mesajul dvs. nu a putut fi trimis" - "Adăugați emoji" - "Acesta este începutul conversației %1$s." + "Adăugați o reacție" + "Acesta este începutul %1$s." "Acesta este începutul acestei conversații." - "Apel neacceptat. Întrebați apelantul dacă poate utiliza noua aplicație Element X." + "Apel incompatibil. Întrebați apelantul dacă poate utiliza noua aplicație Element X." "Afișați mai puțin" "Mesaj copiat" "Nu aveți permisiunea de a posta în această cameră" "%1$d membru a reacționat cu %2$s" "%1$d membri au reacționat cu %2$s" - "%1$d membri au reacționat cu %2$s" + "%1$d de membri au reacționat cu %2$s" - "Dumneavoastră si %1$d membru ați reacționat cu %2$s" - "Dumneavoastră si %1$d membri ați reacționat cu %2$s" - "Dumneavoastră si %1$d membri ați reacționat cu %2$s" + "Dvs. și %1$d membru ați reacționat cu %2$s" + "Dvs. și %1$d membri ați reacționat cu %2$s" + "Dvs. și %1$d de membri ați reacționat cu %2$s" "Ați reacționat cu %1$s" "Afișați mai puțin" @@ -76,14 +85,14 @@ "%1$d mesaj șters" "%1$d mesaje șterse" - "%1$d mesaje șterse" + "%1$d de mesaje șterse" - "%1$d schimbare a camerii" + "%1$d schimbare a camerei" "%1$d schimbări ale camerei" - "%1$d schimbări ale camerei" + "%1$d de schimbări ale camerei" - "Săriți la noua cameră" + "Mergeți la noua cameră" "Această cameră a fost înlocuită și nu mai este activă." "Vedeți mesajele vechi" "Această cameră este o continuare a unei alte camere." diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index 1fab6ed5b3..d23ba8febc 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -49,9 +49,9 @@ "Опрос" "Форматирование текста" "Причина (необязательно)" - "Введите…" + "Укажите причину…" "(Необязательно)" - "Подтвердите удаление" + "Удалить сообщение?" "В настоящее время история сообщений недоступна в этой комнате." "История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений." "Хотите пригласить их снова?" diff --git a/features/messages/impl/src/main/res/values-uk/translations.xml b/features/messages/impl/src/main/res/values-uk/translations.xml index 9c06cb7f47..530cef6919 100644 --- a/features/messages/impl/src/main/res/values-uk/translations.xml +++ b/features/messages/impl/src/main/res/values-uk/translations.xml @@ -49,9 +49,9 @@ "Опитування" "Форматування тексту" "Причина (необов\'язково)" - "Ввести…" + "Ввести причину…" "(Необов\'язково)" - "Підтвердити вилучення" + "Вилучити повідомлення?" "Історія повідомлень наразі недоступна." "Історія повідомлень недоступна в цій кімнаті. Звірте цей пристрій, щоб побачити історію повідомлень." "Чи хотіли б ви запросити їх знову?" 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 6bd94f8a6b..ce8c7a86e2 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,11 @@ "由未知或已刪除的裝置加密。" "由未經其擁有者驗證的裝置加密。" "由未經驗證的使用者加密。" + "無效的 MIME 類型:%s" + "缺少媒體內容" + "錯誤詳細資訊:%s" + "從未經驗證的裝置傳送" + "未知錯誤" "水平翻轉圖片" "水平翻轉" "原始狀態" @@ -41,6 +46,10 @@ "分享位置" "投票" "格式化文字" + "理由(選擇性)" + "輸入⋯⋯" + "(選擇性)" + "確認移除" "目前無法檢視訊息歷史紀錄。" "無法檢視此聊天室的訊息歷史紀錄。驗證此裝置以檢視您的訊息紀錄。" "您想要邀請他們回來嗎?" diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml index eaf429be01..20e275a959 100644 --- a/features/messages/impl/src/main/res/values-zh/translations.xml +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -47,9 +47,9 @@ "投票" "文本格式化" "理由(可选)" - "输入…" + "输入理由…" "(可选)" - "确认移除" + "移除消息?" "消息历史记录当前不可用。" "消息历史在此房间不可用。请验证此设备以查看。" "你想邀请他们回来吗?" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 31f6e4604e..c8ce530e27 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -20,7 +20,7 @@ import kotlinx.collections.immutable.ImmutableList class FakeMessagesNavigator( private val onShowEventDebugInfoClickLambda: (eventId: EventId?, debugInfo: TimelineItemDebugInfo) -> Unit = { _, _ -> lambdaError() }, - private val onForwardEventClickLambda: (eventId: EventId, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() }, + private val onForwardEventClickLambda: (eventIds: List, timelineProvider: TimelineProvider) -> Unit = { _, _ -> lambdaError() }, private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList, inReplyToEventId: EventId?) -> Unit = { _, _ -> lambdaError() }, @@ -35,8 +35,8 @@ class FakeMessagesNavigator( onShowEventDebugInfoClickLambda(eventId, debugInfo) } - override fun forwardEvent(eventId: EventId, timelineProvider: TimelineProvider) { - onForwardEventClickLambda(eventId, timelineProvider) + override fun forwardEvents(eventIds: List, timelineProvider: TimelineProvider) { + onForwardEventClickLambda(eventIds, timelineProvider) } override fun navigateToReportMessage(eventId: EventId, senderId: UserId) { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 858a1f345f..3d3187af96 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.pinned.banner.aLoadedPinnedMess import io.element.android.features.messages.impl.threads.list.aThreadListItem import io.element.android.features.messages.impl.timeline.FakeMarkAsFullyRead import io.element.android.features.messages.impl.timeline.MarkAsFullyRead +import io.element.android.features.messages.impl.timeline.SelectionAction +import io.element.android.features.messages.impl.timeline.SelectionState 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.TimelineState +import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent @@ -78,6 +82,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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_CAPTION import io.element.android.libraries.matrix.test.A_REASON @@ -88,7 +93,6 @@ 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 -import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser @@ -118,6 +122,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testWithLifecycleOwner import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -289,17 +294,37 @@ class MessagesPresenterTest { } @Test - fun `present - handle action forward`() = runTest { - val onForwardEventClickLambda = lambdaRecorder { _, _ -> } - val navigator = FakeMessagesNavigator( - onForwardEventClickLambda = onForwardEventClickLambda, + fun `present - handle action forward enters selection mode when multi select is enabled`() = runTest { + val timelineEventSink = lambdaRecorder { } + val presenter = createMessagesPresenter( + timelineEventSink = timelineEventSink, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), ) - val presenter = createMessagesPresenter(navigator = navigator) presenter.testWithLifecycleOwner { val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) - onForwardEventClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID), any()) + timelineEventSink.assertions().isCalledOnce() + .with(value(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID))) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - handle action forward navigates directly when multi select is disabled`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, TimelineProvider, Unit> { _, _ -> } + val navigator = FakeMessagesNavigator( + onForwardEventClickLambda = onForwardEventClickLambda, + ) + val presenter = createMessagesPresenter( + navigator = navigator, + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Forward, aMessageEvent())) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID)), any()) } } @@ -620,24 +645,24 @@ class MessagesPresenterTest { } @Test - fun `present - handle action redact - a message which was never sent is redacted without asking`() = runTest { + fun `present - handle action redact - a message which was never sent is removed from the send queue without asking`() = runTest { val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) - val liveTimeline = FakeTimeline() - val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) } - liveTimeline.redactEventLambda = redactEventLambda + val abortSendLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } val presenter = createMessagesPresenter( - timeline = liveTimeline, + timeline = FakeTimeline(abortSendResult = abortSendLambda), coroutineDispatchers = coroutineDispatchers, ) presenter.testWithLifecycleOwner { val initialState = awaitItem() - val localEcho = aMessageEvent(eventId = null, transactionId = A_TRANSACTION_ID) + val localEcho = aMessageEvent( + eventId = null, + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.Failed.Unknown("Error"), + ) initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.Redact, localEcho)) advanceUntilIdle() assertThat(expectMostRecentItem().redactEventAction).isEqualTo(AsyncAction.Uninitialized) - assert(redactEventLambda) - .isCalledOnce() - .with(value(localEcho.eventOrTransactionId), value(null)) + abortSendLambda.assertions().isCalledOnce().with(value(localEcho.eventOrTransactionId)) } } @@ -1151,29 +1176,27 @@ class MessagesPresenterTest { @Test fun `present - handle action retry sending`() = runTest { - val retryLambda = lambdaRecorder> { Result.success(Unit) } + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } val messageEvent = aMessageEvent( sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, ) - val presenter = createMessagesPresenter() + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) advanceUntilIdle() - retryLambda.assertions().isCalledOnce() + retryLambda.assertions().isCalledOnce().with(value(messageEvent.eventOrTransactionId)) } } @Test fun `present - handle action retry sending - failure is ignored`() = runTest { - val retryLambda = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.failure(AN_EXCEPTION) } val messageEvent = aMessageEvent( sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { FakeSendHandle(retryLambda = retryLambda) }, ) - val presenter = createMessagesPresenter() + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() @@ -1184,18 +1207,16 @@ class MessagesPresenterTest { } @Test - fun `present - handle action retry sending - no send handle, it should have no effect`() = runTest { - val messageEvent = aMessageEvent( - sendState = LocalEventSendState.Failed.Unknown("Error"), - sendHandleProvider = { null }, - ) - val presenter = createMessagesPresenter() + fun `present - handle action retry sending - no pending send state, it should have no effect`() = runTest { + val retryLambda = lambdaRecorder { _: EventOrTransactionId -> Result.success(true) } + val messageEvent = aMessageEvent(sendState = null) + val presenter = createMessagesPresenter(timeline = FakeTimeline(retrySendResult = retryLambda)) presenter.testWithLifecycleOwner { skipItems(1) val initialState = awaitItem() initialState.eventSink(MessagesEvent.HandleAction(TimelineItemAction.RetrySending, messageEvent)) advanceUntilIdle() - // No op! + retryLambda.assertions().isNeverCalled() } } @@ -1501,6 +1522,49 @@ class MessagesPresenterTest { canPinUnpin = canPinUnpin, ) + @Test + fun `present - confirm selection action forwards the selection in timeline order`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, TimelineProvider, Unit> { _, _ -> } + val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda) + val timelineEventSink = lambdaRecorder { } + val presenter = createMessagesPresenter( + navigator = navigator, + timelineEventSink = timelineEventSink, + timelineState = aTimelineState( + // The timeline is rendered from the most recent event to the oldest one. + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + selectionState = SelectionState.Active( + SelectionAction.Forward, + persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID), + ), + eventSink = timelineEventSink, + ), + ) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.ConfirmSelectionAction) + onForwardEventClickLambda.assertions().isCalledOnce().with(value(listOf(AN_EVENT_ID, AN_EVENT_ID_2)), any()) + timelineEventSink.assertions().isCalledOnce().with(value(TimelineEvent.ExitSelectionMode)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - confirm selection action does nothing when no selection is active`() = runTest { + val onForwardEventClickLambda = lambdaRecorder, TimelineProvider, Unit> { _, _ -> } + val navigator = FakeMessagesNavigator(onForwardEventClickLambda = onForwardEventClickLambda) + val presenter = createMessagesPresenter(navigator = navigator) + presenter.testWithLifecycleOwner { + val initialState = awaitItem() + initialState.eventSink(MessagesEvent.ConfirmSelectionAction) + onForwardEventClickLambda.assertions().isNeverCalled() + cancelAndIgnoreRemainingEvents() + } + } + private fun TestScope.createMessagesPresenter( coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), timeline: Timeline = FakeTimeline(), @@ -1523,6 +1587,7 @@ class MessagesPresenterTest { clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), timelineEventSink: (TimelineEvent) -> Unit = {}, + timelineState: TimelineState = aTimelineState(eventSink = timelineEventSink), permalinkParser: PermalinkParser = FakePermalinkParser(), messageComposerPresenter: Presenter = Presenter { aMessageComposerState( @@ -1545,7 +1610,7 @@ class MessagesPresenterTest { room = joinedRoom, composerPresenter = messageComposerPresenter, voiceMessageComposerPresenterFactory = FakeDefaultVoiceMessageComposerPresenterFactory(backgroundScope), - timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, + timelinePresenter = { timelineState }, timelineProtectionPresenter = { aTimelineProtectionState() }, identityChangeStatePresenter = { anIdentityChangeState() }, linkPresenter = { aLinkState() }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 677b2ac10b..e8613d3547 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -606,7 +606,7 @@ class MessagesViewTest : RobolectricTest() { timelineEventsRecorder.clear() clickOn(CommonStrings.action_remove_message) - messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Redact, timelineItem)) + messagesEventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.AbortSending, timelineItem)) timelineEventsRecorder.assertSingle(TimelineEvent.HideSendFailureDialog) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 1b9394f2ee..87dbd2119b 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -43,7 +43,7 @@ internal fun aMessageEvent( content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = A_MESSAGE, isEdited = false), inReplyTo: InReplyToDetails? = null, threadInfo: TimelineItemThreadInfo? = null, - sendState: LocalEventSendState = LocalEventSendState.Sent(AN_EVENT_ID), + sendState: LocalEventSendState? = LocalEventSendState.Sent(AN_EVENT_ID), debugInfoProvider: TimelineItemDebugInfoProvider = TimelineItemDebugInfoProvider { aTimelineItemDebugInfo() }, messageShieldProvider: MessageShieldProvider = MessageShieldProvider { null }, sendHandleProvider: SendHandleProvider = SendHandleProvider { FakeSendHandle() } @@ -62,6 +62,8 @@ internal fun aMessageEvent( reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = sendState, + editSendState = null, + redactionSendState = null, inReplyTo = inReplyTo, threadInfo = threadInfo, origin = null, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index a8f6c338f5..4535607cb6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled 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 3253e6088d..74b8047449 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 @@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt new file mode 100644 index 0000000000..1d7155f742 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionActionTest.kt @@ -0,0 +1,60 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.aStaticLocationMode +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStickerContent +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import org.junit.Test + +class SelectionActionTest { + @Test + fun `Forward - a remote text message can be selected`() { + assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent())).isTrue() + } + + @Test + fun `Forward - a local echo cannot be selected`() { + assertThat(SelectionAction.Forward.canApplyTo(aTimelineItemEvent().copy(eventId = null))).isFalse() + } + + @Test + fun `Forward - a failed to send message cannot be selected`() { + // A message which failed to send is a local echo: it has no event id. + val item = aTimelineItemEvent(sendState = LocalEventSendState.Failed.Unknown("failed")).copy(eventId = null) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a state event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemStateEventContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a redacted event cannot be selected`() { + val item = aTimelineItemEvent(content = aTimelineItemRedactedContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a sticker cannot be selected, it cannot be forwarded`() { + val item = aTimelineItemEvent(content = aTimelineItemStickerContent()) + assertThat(SelectionAction.Forward.canApplyTo(item)).isFalse() + } + + @Test + fun `Forward - a static location can be selected, it can be forwarded`() { + val item = aTimelineItemEvent(content = aTimelineItemLocationContent(mode = aStaticLocationMode())) + assertThat(SelectionAction.Forward.canApplyTo(item)).isTrue() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.kt new file mode 100644 index 0000000000..920b3bdaae --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/SelectionStateTest.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. + */ + +package io.element.android.features.messages.impl.timeline + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.AN_EVENT_ID_3 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet +import org.junit.Test + +class SelectionStateTest { + @Test + fun `eventIdsToDeselect - selection mode being disabled has nothing to drop`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent())) + assertThat(SelectionState.Disabled.eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - an empty selection has nothing to drop`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID, content = aTimelineItemRedactedContent())) + assertThat(anActiveSelection().eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - a selected event the action still applies to is kept`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID)) + assertThat(anActiveSelection(listOf(AN_EVENT_ID)).eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - a selected event which has been deleted is dropped`() { + val items = listOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ) + assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `eventIdsToDeselect - a selected event which is not in the timeline is ignored`() { + val items = listOf(aTimelineItemEvent(eventId = AN_EVENT_ID)) + // AN_EVENT_ID_3 may simply not be loaded anymore, so it is not dropped here. + assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_3)).eventIdsToDeselect(items)).isEmpty() + } + + @Test + fun `eventIdsToDeselect - a deleted selected event folded into a group is dropped`() { + val items = listOf( + TimelineItem.GroupedEvents( + id = UniqueId("group"), + events = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()), + aTimelineItemEvent(eventId = AN_EVENT_ID_3, content = aTimelineItemRedactedContent()), + ), + aggregatedReadReceipts = persistentListOf(), + ), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ) + assertThat(anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)).eventIdsToDeselect(items)).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `deselect - removes the given events and keeps selection mode active`() { + val selection = anActiveSelection(listOf(AN_EVENT_ID, AN_EVENT_ID_2)) + assertThat(selection.deselect(setOf(AN_EVENT_ID))).isEqualTo(anActiveSelection(listOf(AN_EVENT_ID_2))) + assertThat(selection.deselect(setOf(AN_EVENT_ID, AN_EVENT_ID_2))).isEqualTo(anActiveSelection()) + } + + @Test + fun `deselect - does nothing when selection mode is disabled`() { + assertThat(SelectionState.Disabled.deselect(setOf(AN_EVENT_ID))).isEqualTo(SelectionState.Disabled) + } + + private fun anActiveSelection(eventIds: List = emptyList()) = SelectionState.Active( + action = SelectionAction.Forward, + selectedEventIds = eventIds.toImmutableSet(), + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index aa4198d156..5f161bfea0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -33,7 +33,12 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.test.actions.FakeEndPollAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId @@ -73,6 +78,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem @@ -86,6 +92,7 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay @@ -1541,6 +1548,38 @@ class TimelinePresenterTest { } } + @Test + fun `present - copy to clipboard on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createTimelinePresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy to clipboard on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createTimelinePresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - show shield hide shield`() = runTest { val presenter = createTimelinePresenter() @@ -1824,6 +1863,152 @@ class TimelinePresenterTest { } } + @Test + fun `present - entering selection mode selects the target event`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + val state = consumeItemsUntilPredicate { it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID))) + assertThat(state.selectedCount).isEqualTo(1) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - entering selection mode is ignored when the flag is off`() = runTest { + val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to false)) + val presenter = createTimelinePresenter(featureFlagService = featureFlagService) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + expectNoEvents() + assertThat(initialState.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - toggling the last selected event keeps selection mode active`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID)) + // Selection mode is only exited explicitly, so an empty selection keeps it active. + val state = consumeItemsUntilPredicate { it.selectedCount == 0 }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf())) + assertThat(state.isSelectionModeActive).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - exiting selection mode clears the selection`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + initialState.eventSink(TimelineEvent.ExitSelectionMode) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - turning the flag off clears the active selection`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = featureFlagService, + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + featureFlagService.setFeatureEnabled(FeatureFlags.MessageMultiSelect, false) + val state = consumeItemsUntilPredicate { !it.isSelectionModeActive }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Disabled) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - a selected event which cannot be forwarded anymore is deselected`() = runTest { + val timelineItems = MutableStateFlow( + listOf( + MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent())), + MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())), + ) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + initialState.eventSink(TimelineEvent.ToggleSelection(AN_EVENT_ID_2)) + consumeItemsUntilPredicate { it.selectedCount == 2 } + // The first message is deleted, so it cannot be forwarded anymore. + timelineItems.value = listOf( + MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aRedactedContent())), + MatrixTimelineItem.Event(UniqueId("2"), anEventTimelineItem(eventId = AN_EVENT_ID_2, content = aMessageContent())), + ) + val state = consumeItemsUntilPredicate { it.selectedCount == 1 }.last() + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2))) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - the selection survives a timeline which no longer contains the selected event`() = runTest { + val timelineItems = MutableStateFlow( + listOf(MatrixTimelineItem.Event(UniqueId("1"), anEventTimelineItem(eventId = AN_EVENT_ID, content = aMessageContent()))) + ) + val presenter = createTimelinePresenter( + timeline = FakeTimeline(timelineItems = timelineItems), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.MessageMultiSelect.key to true)), + ) + presenter.test { + val initialState = consumeItemsUntilPredicate { it.hasAnyEvent }.last() + initialState.eventSink(TimelineEvent.EnterSelectionMode(SelectionAction.Forward, AN_EVENT_ID)) + consumeItemsUntilPredicate { it.isSelectionModeActive } + // The timeline no longer holds the selected event, for instance because it has been reset or because it switched to a detached timeline. + timelineItems.value = emptyList() + val state = consumeItemsUntilPredicate { !it.hasAnyEvent }.last() + // The selection is the user intent: it is only validated when the action is confirmed, so that a transient timeline update cannot drop it. + assertThat(state.selectionState).isEqualTo(SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID))) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } @@ -1897,6 +2082,8 @@ class TimelinePresenterTest { markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, timelineProtectionPresenter: Presenter = { aTimelineProtectionState() }, resolveVerifiedUserSendFailurePresenter: Presenter = { aResolveVerifiedUserSendFailureState() }, + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): TimelinePresenter { return TimelinePresenter( timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), @@ -1919,10 +2106,13 @@ class TimelinePresenterTest { typingNotificationPresenter = { aTypingNotificationState() }, roomCallStatePresenter = { aStandByCallState() }, featureFlagService = featureFlagService, + snackbarDispatcher = SnackbarDispatcher(), analyticsService = FakeAnalyticsService(), liveLocationShareManager = liveLocationShareManager, markAsFullyRead = markAsFullyRead, timelineProtectionPresenter = timelineProtectionPresenter, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt new file mode 100644 index 0000000000..8e3cb997c2 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateTest.kt @@ -0,0 +1,77 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemRedactedContent +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +import io.element.android.libraries.matrix.test.AN_EVENT_ID_3 +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test + +class TimelineStateTest { + @Test + fun `selectedEventIdsInTimelineOrder - when selection is disabled, the list is empty`() { + val state = aTimelineState( + timelineItems = persistentListOf(aTimelineItemEvent(eventId = AN_EVENT_ID)), + selectionState = SelectionState.Disabled, + ) + assertThat(state.selectedEventIdsInTimelineOrder()).isEmpty() + } + + @Test + fun `selectedEventIdsInTimelineOrder - the events are sorted from the oldest to the most recent one`() { + // The timeline items are rendered from the most recent to the oldest event. + val state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_3), + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + // The events have been tapped in a random order. + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID, AN_EVENT_ID_3)), + ) + assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID, AN_EVENT_ID_2, AN_EVENT_ID_3).inOrder() + } + + @Test + fun `selectedEventIdsInTimelineOrder - the events which are not in the timeline are ignored`() { + val state = aTimelineState( + timelineItems = persistentListOf( + aTimelineItemEvent(eventId = AN_EVENT_ID_2), + aTimelineItemEvent(eventId = AN_EVENT_ID), + ), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID_2, AN_EVENT_ID_3)), + ) + assertThat(state.selectedEventIdsInTimelineOrder()).containsExactly(AN_EVENT_ID_2) + } + + @Test + fun `canSelect - returns false when selection mode is disabled`() { + val event = aTimelineItemEvent(eventId = AN_EVENT_ID) + val state = aTimelineState( + timelineItems = persistentListOf(event), + selectionState = SelectionState.Disabled, + ) + assertThat(state.canSelect(event)).isFalse() + } + + @Test + fun `canSelect - follows the action predicate when selection mode is active`() { + val forwardable = aTimelineItemEvent(eventId = AN_EVENT_ID) + val notForwardable = aTimelineItemEvent(eventId = AN_EVENT_ID_2, content = aTimelineItemRedactedContent()) + val state = aTimelineState( + timelineItems = persistentListOf(forwardable, notForwardable), + selectionState = SelectionState.Active(SelectionAction.Forward, persistentSetOf(AN_EVENT_ID)), + ) + assertThat(state.canSelect(forwardable)).isTrue() + assertThat(state.canSelect(notForwardable)).isFalse() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index f6ef4f0fc2..b9bb13649a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -43,6 +43,8 @@ class TimelineItemGrouperTest { reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), localSendState = LocalEventSendState.Sent(AN_EVENT_ID), + editSendState = null, + redactionSendState = null, isEditable = false, canBeRepliedTo = false, inReplyTo = null, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index e4c3310089..8fac783581 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.messagecomposer.aReplyMode import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.timeline.Timeline @@ -28,7 +29,6 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.impl.DefaultMediaSender diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt index 9a5eda366b..ae832ade45 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/RedactedVoiceMessageManagerTest.kt @@ -83,6 +83,8 @@ fun aRedactedMatrixTimeline(eventId: EventId) = listOf( isOwn = false, isRemote = false, localSendState = null, + editSendState = null, + redactionSendState = null, reactions = persistentListOf(), receipts = persistentListOf(), sender = A_USER_ID, diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt index 466bfba4fd..7f608fe283 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -11,8 +11,8 @@ package io.element.android.features.messages.test.timeline.voicemessages.compose import io.element.android.features.messages.impl.voicemessages.composer.DefaultVoiceMessageComposerPresenter import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.test.FakeMediaSender diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt similarity index 90% rename from features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt rename to features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt index 471ee30d22..57faede46e 100644 --- a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationEntryPoint.kt +++ b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.api +package io.element.android.features.migration.api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt similarity index 88% rename from features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt rename to features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt index 000d898a6a..ca5b9a4882 100644 --- a/features/migration/api/src/main/kotlin/io/element/android/features/api/MigrationState.kt +++ b/features/migration/api/src/main/kotlin/io/element/android/features/migration/api/MigrationState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.api +package io.element.android.features.migration.api import io.element.android.libraries.architecture.AsyncData diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt index edefa7ce75..df5f38a797 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/DefaultMigrationEntryPoint.kt @@ -12,8 +12,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.api.MigrationEntryPoint -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationEntryPoint +import io.element.android.features.migration.api.MigrationState @ContributesBinding(AppScope::class) class DefaultMigrationEntryPoint( diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt index 2d78e5f706..1301c0e309 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationPresenter.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.features.migration.impl.migrations.AppMigration import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt index de25284867..b43681427e 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationStatePreviewParam.kt @@ -9,7 +9,7 @@ package io.element.android.features.migration.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.libraries.architecture.AsyncData internal class MigrationStatePreviewParam : PreviewParameterProvider { diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt index c7f4dd678a..c3f32eb06b 100644 --- a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/MigrationView.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.api.MigrationState +import io.element.android.features.migration.api.MigrationState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt index 46bde271c4..8451d1b07f 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration08Test.kt @@ -10,7 +10,7 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat import io.element.android.features.announcement.api.Announcement -import io.element.android.features.rageshake.test.logs.FakeAnnouncementService +import io.element.android.features.announcement.test.FakeAnnouncementService import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value diff --git a/features/networkmonitor/impl/build.gradle.kts b/features/networkmonitor/impl/build.gradle.kts index 5adc383d64..e08e7e0f78 100644 --- a/features/networkmonitor/impl/build.gradle.kts +++ b/features/networkmonitor/impl/build.gradle.kts @@ -21,11 +21,8 @@ android { dependencies { implementation(libs.coroutines.core) - implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.features.networkmonitor.api) testCommonDependencies(libs) - testImplementation(projects.libraries.matrix.test) - testImplementation(projects.features.networkmonitor.test) } diff --git a/features/poll/api/src/main/res/values-ro/translations.xml b/features/poll/api/src/main/res/values-ro/translations.xml index ef86b4ff16..1f158430b5 100644 --- a/features/poll/api/src/main/res/values-ro/translations.xml +++ b/features/poll/api/src/main/res/values-ro/translations.xml @@ -1,10 +1,10 @@ - "%1$d la suta din totalul voturilor" - "%1$d la suta din totalul voturilor" - "%1$d la suta din totalul voturilor" + "%1$d % din totalul voturilor" + "%1$d % din totalul voturilor" + "%1$d % din totalul voturilor" "Va șterge selecția anterioară" - "Acesta este votul câștigător" + "Acesta este răspunsul câștigător" diff --git a/features/poll/impl/build.gradle.kts b/features/poll/impl/build.gradle.kts index 175f7b4471..0d5790639e 100644 --- a/features/poll/impl/build.gradle.kts +++ b/features/poll/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.services.analytics.api) implementation(projects.features.messages.api) 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 602466b582..28a8179131 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 @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -216,7 +215,6 @@ fun CreatePollView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CreatePollTopAppBar( mode: CreatePollState.Mode, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 40bc2f893a..e9298e9c27 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -53,7 +52,6 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PollHistoryView( state: PollHistoryState, @@ -125,7 +123,6 @@ fun PollHistoryView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PollHistoryFilterButtons( activeFilter: PollHistoryFilter, diff --git a/features/poll/impl/src/main/res/values-ro/translations.xml b/features/poll/impl/src/main/res/values-ro/translations.xml index 65cf16ebcb..0ec42edc78 100644 --- a/features/poll/impl/src/main/res/values-ro/translations.xml +++ b/features/poll/impl/src/main/res/values-ro/translations.xml @@ -4,17 +4,17 @@ "Afișați rezultatele numai după încheierea sondajului" "Sondaj anonim" "Opțiune %1$d" - "Modificările dumneavoastră nu au fost salvate. Sunteți sigur că doriți să vă întoarceți?" + "Modificările dvs. nu au fost salvate. Sigur doriți să reveniți?" "Ștergeți opțiunea %1$s" "Întrebare sau subiect" "Despre ce este sondajul?" "Creați un sondaj" - "Sunteți sigur că doriți să ștergeți acest sondaj?" + "Sigur doriți să ștergeți acest sondaj?" "Ștergeți sondajul" "Editați sondajul" - "Nu s-au putut găsi sondaje în curs de desfășurare." - "Nu s-au putut găsi sondaje anterioare." + "Nu s-au putut găsi sondaje în desfășurare." + "Nu s-au putut găsi sondaje încheiate." "În desfășurare" - "Trecut" + "Încheiate" "Sondaje" diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index e7fbe6069f..a54fc98438 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { @@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToLinkNewDevice() fun navigateToBugReport() fun navigateToSecureBackup() diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index 11f863e26d..277d78b50c 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -48,15 +48,12 @@ setupDependencyInjection() dependencies { implementation(projects.libraries.androidutils) - implementation(projects.appconfig) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.cachestore.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.ui) - implementation(projects.libraries.network) implementation(projects.libraries.pushstore.api) implementation(projects.libraries.indicator.api) implementation(projects.libraries.preferences.api) @@ -78,16 +75,15 @@ dependencies { implementation(projects.features.analytics.api) implementation(projects.features.enterprise.api) implementation(projects.features.licenses.api) + implementation(projects.features.location.api) implementation(projects.features.logout.api) implementation(projects.features.deactivation.api) - implementation(projects.features.home.api) implementation(projects.features.invite.api) implementation(projects.libraries.emoji.api) implementation(projects.services.analytics.api) implementation(projects.services.analytics.compose) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) - implementation(libs.datetime) implementation(libs.coil.compose) implementation(libs.color.picker) implementation(libs.androidx.browser) @@ -108,19 +104,17 @@ dependencies { testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) - testImplementation(projects.libraries.roomselect.test) testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.features.deactivation.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.licenses.test) + testImplementation(projects.features.location.test) testImplementation(projects.features.lockscreen.test) - testImplementation(projects.features.rageshake.test) testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) testImplementation(projects.libraries.pushproviders.test) - testImplementation(projects.libraries.cachestore.test) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.services.appnavstate.impl) testImplementation(projects.services.analytics.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 5d8bb2bf42..a49053546d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint @@ -141,6 +142,10 @@ class PreferencesFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToAnalyticsSettings() { backstack.push(NavTarget.AnalyticsSettings) } @@ -186,7 +191,7 @@ class PreferencesFlowNode( } override fun navigateToLinkNewDevice() { - callback.navigateToAddAccount() + callback.navigateToLinkNewDevice() } override fun navigateToUserProfile(matrixUser: MatrixUser) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt index ad9686be9c..e20deb91cb 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -34,7 +33,6 @@ class PreferencesAccountPresenter( private val sessionVerificationService: SessionVerificationService, private val snackbarDispatcher: SnackbarDispatcher, private val indicatorService: IndicatorService, - private val directLogoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val featureFlagService: FeatureFlagService, private val sessionEnterpriseService: SessionEnterpriseService, @@ -42,10 +40,7 @@ class PreferencesAccountPresenter( @Composable override fun present(): PreferencesAccountState { val matrixUser = matrixClient.userProfile.collectAsState() - LaunchedEffect(Unit) { - // Force a refresh of the profile - matrixClient.getUserProfile() - } + // Note: no need to force a refresh of the profile here, PreferencesRootPresenter, which embeds this presenter, already does it. val showLinkNewDevice by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) }.collectAsState(initial = false) @@ -65,8 +60,6 @@ class PreferencesAccountPresenter( canDeactivateAccount = matrixClient.canDeactivateAccount() } - val directLogoutState = directLogoutPresenter.present() - val accountManagementUrl by produceState(initialValue = null) { value = matrixClient.getAccountManagementUrl(null) .getOrNull() @@ -83,7 +76,6 @@ class PreferencesAccountPresenter( canReportBug = canReportBug, showLinkNewDevice = showLinkNewDevice, canDeactivateAccount = canDeactivateAccount, - directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt index 00abbf2ad4..eaf65328f0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountState.kt @@ -7,7 +7,6 @@ package io.element.android.features.preferences.impl.account -import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser @@ -19,6 +18,5 @@ data class PreferencesAccountState( val canReportBug: Boolean, val showLinkNewDevice: Boolean, val canDeactivateAccount: Boolean, - val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt index 0cf42104ef..2308b75d43 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountStatePreviewParam.kt @@ -8,8 +8,6 @@ package io.element.android.features.preferences.impl.account import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.features.logout.api.direct.aDirectLogoutState 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.aMatrixUser @@ -56,7 +54,6 @@ fun aPreferencesAccountState( canReportBug: Boolean = false, showLinkNewDevice: Boolean = false, canDeactivateAccount: Boolean = false, - directLogoutState: DirectLogoutState = aDirectLogoutState(), snackbarMessage: SnackbarMessage? = null, ) = PreferencesAccountState( myUser = myUser, @@ -66,6 +63,5 @@ fun aPreferencesAccountState( canReportBug = canReportBug, showLinkNewDevice = showLinkNewDevice, canDeactivateAccount = canDeactivateAccount, - directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, ) 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 8f20694d81..ebb671e6d0 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 @@ -81,8 +81,9 @@ class BlockedUsersPresenter( unblockUserAction.value = AsyncAction.ConfirmingNoParams } is BlockedUsersEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.userId.value) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.userId.value) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } BlockedUsersEvent.ConfirmUnblock -> { pendingUserToUnblock?.let { 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 be6c0ef170..5a1df1d7d6 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -38,7 +37,6 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockedUsersView( state: BlockedUsersState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt index 34ccba2abe..a4ca35e50d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt @@ -19,4 +19,5 @@ sealed interface DeveloperSettingsEvent { data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent data object OpenPushRules : DeveloperSettingsEvent data object DismissPushRulesError : DeveloperSettingsEvent + data class CopyToClipboard(val text: String) : DeveloperSettingsEvent } 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 74bf595ce4..d531bb1cb1 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 @@ -26,7 +26,9 @@ import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -38,6 +40,7 @@ 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 io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope @@ -61,6 +64,8 @@ class DeveloperSettingsPresenter( private val markAllRoomsAsRead: MarkAllRoomsAsRead, private val buildMeta: BuildMeta, private val notificationSettingsService: NotificationSettingsService, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory fun interface Factory { @@ -130,6 +135,11 @@ class DeveloperSettingsPresenter( DeveloperSettingsEvent.DismissPushRulesError -> { pushRulesAction.value = AsyncAction.Uninitialized } + is DeveloperSettingsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } 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 992a7e6212..b36521ef0c 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,14 +15,12 @@ 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.architecture.AsyncAction import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog @@ -84,7 +82,10 @@ fun DeveloperSettingsView( AppDeveloperSettingsView( state = state.appDeveloperSettingsState, ) - SessionCategory(deviceId = state.deviceId) + SessionCategory( + deviceId = state.deviceId, + onCopyToClipboard = { state.eventSink(DeveloperSettingsEvent.CopyToClipboard(it)) }, + ) NotificationCategory( onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) }, onPushHistoryClick = onPushHistoryClick, @@ -182,19 +183,15 @@ fun DeveloperSettingsView( } @Composable -private fun SessionCategory(deviceId: DeviceId) { +private fun SessionCategory( + deviceId: DeviceId, + onCopyToClipboard: (String) -> Unit, +) { 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, - ) - } + onClick = { onCopyToClipboard(deviceId.value) } ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt index 9f328fdd9b..b3d777c4e7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt @@ -17,4 +17,5 @@ sealed interface AppDeveloperSettingsEvent { data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent + data class CopyToClipboard(val text: String) : AppDeveloperSettingsEvent } 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 1bf162d3ea..75519079ed 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 @@ -22,6 +22,8 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.model.EnabledFeature import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -29,6 +31,7 @@ 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 +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -44,6 +47,8 @@ class AppDeveloperSettingsPresenter( private val rageshakePresenter: Presenter, private val appPreferencesStore: AppPreferencesStore, private val buildMeta: BuildMeta, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @Composable override fun present(): AppDeveloperSettingsState { @@ -106,6 +111,11 @@ class AppDeveloperSettingsPresenter( } appPreferencesStore.setTracingLogPacks(currentPacks) } + is AppDeveloperSettingsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } 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 7f10745872..4f6f8234fa 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,7 +14,6 @@ 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 @@ -23,7 +22,6 @@ 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.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown @@ -108,6 +106,7 @@ fun AppDeveloperSettingsView( GitCategory( gitBranch = state.gitBranch, gitSha = state.gitSha, + onCopyToClipboard = { state.eventSink(AppDeveloperSettingsEvent.CopyToClipboard(it)) }, ) } } @@ -116,29 +115,18 @@ fun AppDeveloperSettingsView( private fun GitCategory( gitBranch: String, gitSha: String, + onCopyToClipboard: (String) -> Unit, ) { 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, - ) - } + onClick = { onCopyToClipboard(gitBranch) } ) ListItem( content = { Text("Git SHA") }, supportingContent = { Text(text = gitSha) }, - onClick = { - context.copyToClipboard( - text = gitSha, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(gitSha) } ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt index c518b346eb..4685718ae2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsView.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -40,7 +39,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar * The contents of the Labs screen. * Design: https://www.figma.com/design/V0dkfRAW6T3yCQKjahpzkX/ER-46-EX--Threads?node-id=2004-27319&t=yssy1yYYigsGON3s-0 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LabsView( state: LabsState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt index fc175f4c0b..b356d154a7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvent.kt @@ -34,4 +34,5 @@ sealed interface NotificationSettingsEvent { data object DismissCallRingtoneDialog : NotificationSettingsEvent data class SelectCallRingtonePreset(val sound: NotificationSound) : NotificationSettingsEvent data object LaunchCallRingtonePicker : NotificationSettingsEvent + data class ToggleShowAllActivityInRoomList(val newValue: Boolean) : NotificationSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 5256f9ec9c..c08427f387 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -26,8 +26,11 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingStateNoSuccess +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService @@ -55,6 +58,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds @@ -73,6 +77,8 @@ class NotificationSettingsPresenter( private val stringProvider: StringProvider, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, + private val dispatchers: CoroutineDispatchers, ) : Presenter { // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // its `.tmp` file, but the persist+recreate window outside the copier is unprotected; @@ -108,16 +114,19 @@ class NotificationSettingsPresenter( } // List of PushProvider -> Distributor - val distributors = remember { - pushService.getAvailablePushProviders() - .flatMap { pushProvider -> - pushProvider.getDistributors().map { distributor -> - pushProvider to distributor + // Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread + val distributors by produceState(emptyList>()) { + value = withContext(dispatchers.io) { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } } - } + } } // List of Distributors - val availableDistributors = remember { + val availableDistributors = remember(distributors) { distributors.map { it.second }.toImmutableList() } @@ -196,6 +205,11 @@ class NotificationSettingsPresenter( ) } + val showAllActivityInRoomList by produceState(false) { + featureFlagService.isFeatureEnabledFlow(ShowAllActivityInRoomListFeature) + .collect { value = it } + } + fun handleEvent(event: NotificationSettingsEvent) { when (event) { is NotificationSettingsEvent.SetAtRoomNotificationsEnabled -> { @@ -276,6 +290,9 @@ class NotificationSettingsPresenter( NotificationSettingsEvent.DismissCallRingtoneCopyError -> { callRingtoneCopyError = false } + is NotificationSettingsEvent.ToggleShowAllActivityInRoomList -> localCoroutineScope.launch { + featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, event.newValue) + } } } @@ -304,6 +321,7 @@ class NotificationSettingsPresenter( showCallRingtoneDialog = showCallRingtoneDialog, pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch, pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch, + showAllActivityInRoomList = showAllActivityInRoomList, eventSink = ::handleEvent, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index 379b8589b7..05a3d29413 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -42,6 +42,10 @@ data class NotificationSettingsState( * auto-opens on screen entry. */ val pendingCallRingtonePickerLaunch: Int, + /** + * Whether to show all activity indicators in the room list, using unread messages instead of notifications. + */ + val showAllActivityInRoomList: Boolean, val eventSink: (NotificationSettingsEvent) -> Unit, ) { sealed interface MatrixSettings { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt index 33f20cdb6e..00011a5ab7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStatePreviewParam.kt @@ -99,6 +99,7 @@ fun aValidNotificationSettingsState( callRingtoneCopyError: Boolean = false, showMessageSoundDialog: Boolean = false, showCallRingtoneDialog: Boolean = false, + showAllActivityInRoomList: Boolean = false, pendingMessageSoundPickerLaunch: Int = 0, pendingCallRingtonePickerLaunch: Int = 0, eventSink: (NotificationSettingsEvent) -> Unit = {}, @@ -133,6 +134,7 @@ fun aValidNotificationSettingsState( showCallRingtoneDialog = showCallRingtoneDialog, pendingMessageSoundPickerLaunch = pendingMessageSoundPickerLaunch, pendingCallRingtonePickerLaunch = pendingCallRingtonePickerLaunch, + showAllActivityInRoomList = showAllActivityInRoomList, eventSink = eventSink, ) @@ -166,6 +168,7 @@ fun aInvalidNotificationSettingsState( showCallRingtoneDialog = false, pendingMessageSoundPickerLaunch = 0, pendingCallRingtonePickerLaunch = 0, + showAllActivityInRoomList = false, eventSink = eventSink, ) 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 21f0cc7b51..e6784b5ce7 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 @@ -199,6 +199,16 @@ private fun NotificationSettingsContentView( onCheckedChange = onMentionNotificationsChange ) } + + PreferenceCategory { + PreferenceSwitch( + title = stringResource(R.string.screen_notification_settings_show_all_activity_title), + subtitle = stringResource(R.string.screen_notification_settings_show_all_activity_subtitle), + isChecked = state.showAllActivityInRoomList, + onCheckedChange = { newValue -> state.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList(newValue)) } + ) + } + SoundsPreferenceCategory(state = state) PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { // TODO We are removing the call notification toggle until support for call notifications has been added diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt new file mode 100644 index 0000000000..7ac47056c2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.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.preferences.impl.root + +import io.element.android.libraries.matrix.api.user.MatrixUser + +/** + * A signed-in account which is not the current one. + * + * @param user the user of the account. + * @param unreadCount the total number of unread notifications for this account. + */ +data class OtherAccount( + val user: MatrixUser, + val unreadCount: Int, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index be2c873a3b..6498c2464b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -8,11 +8,8 @@ package io.element.android.features.preferences.impl.root -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface PreferencesRootEvent { data object OnVersionInfoClick : PreferencesRootEvent - data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent data object ToggleOtherAccountsExpanded : PreferencesRootEvent data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 57ed6294fa..93d6d25cbd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -19,13 +19,12 @@ 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.logout.api.direct.DirectLogoutEvent -import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.preferences.impl.account.PreferencesAccountCallback import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId @ContributesNode(SessionScope::class) @AssistedInject @@ -34,10 +33,10 @@ class PreferencesRootNode( @Assisted plugins: List, private val presenter: PreferencesRootPresenter, private val emojiPickerRenderer: EmojiPickerRenderer, - private val directLogoutView: DirectLogoutView, ) : Node(buildContext, plugins = plugins) { interface Callback : PreferencesAccountCallback, Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToAnalyticsSettings() fun navigateToAbout() fun navigateToDeveloperSettings() @@ -75,6 +74,7 @@ class PreferencesRootNode( modifier = modifier, onBackClick = this::navigateUp, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToSession = callback::navigateToSession, onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAbout = callback::navigateToAbout, onOpenDeveloperSettings = callback::navigateToDeveloperSettings, @@ -89,16 +89,8 @@ class PreferencesRootNode( onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = callback::navigateToNotificationSettings, onEditProfileClick = callback::navigateToUserProfile, - onSignOutClick = { - if (state.preferencesAccountState.directLogoutState.canDoDirectSignOut) { - state.preferencesAccountState.directLogoutState.eventSink(DirectLogoutEvent.Logout(ignoreSdkError = false)) - } else { - callback.startSignOutFlow() - } - }, + onSignOutClick = callback::startSignOutFlow, onDeactivateClick = callback::startAccountDeactivationFlow ) - - directLogoutView.Render(state = state.preferencesAccountState.directLogoutState) } } 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 6bada34e93..f3223b3d37 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 @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme +import io.element.android.features.location.api.LocationService import io.element.android.features.preferences.impl.account.PreferencesAccountState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -29,17 +30,25 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.totalUnreadCountFlow +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +@OptIn(ExperimentalCoroutinesApi::class) @Inject class PreferencesRootPresenter( private val matrixClient: MatrixClient, @@ -48,7 +57,9 @@ class PreferencesRootPresenter( private val snackbarDispatcher: SnackbarDispatcher, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, private val featureFlagService: FeatureFlagService, + private val locationService: LocationService, private val sessionStore: SessionStore, + private val matrixClientProvider: MatrixClientProvider, private val appPreferencesStore: AppPreferencesStore, private val userStatusPresenter: Presenter, private val preferencesAccountPresenter: Presenter, @@ -82,18 +93,20 @@ class PreferencesRootPresenter( }.collectAsState(initial = Theme.System) val otherSessions by remember { - sessionStore.sessionsFlow().map { list -> - list - .filter { it.userId != matrixClient.sessionId.value } - .map { - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - ) + sessionStore.sessionsFlow() + .map { list -> list.filter { it.userId != matrixClient.sessionId.value } } + .flatMapLatest { sessions -> + if (sessions.isEmpty()) { + flowOf(persistentListOf()) + } else { + combine( + sessions.map { sessionData -> + val user = sessionData.toMatrixUser() + unreadCountFlow(user.userId).map { unreadCount -> OtherAccount(user, unreadCount) } + } + ) { it.toImmutableList() } } - .toImmutableList() - } + } }.collectAsState(initial = persistentListOf()) val themeOption by remember { @@ -122,14 +135,13 @@ class PreferencesRootPresenter( val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() + val isLocationServiceAvailable by remember { locationService.isServiceAvailable() }.collectAsState() + fun handleEvent(event: PreferencesRootEvent) { when (event) { is PreferencesRootEvent.OnVersionInfoClick -> { showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } - is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { - sessionStore.setLatestSession(event.sessionId.value) - } PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch { appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded) } @@ -157,8 +169,14 @@ class PreferencesRootPresenter( showAnalyticsSettings = hasAnalyticsProviders, showDeveloperSettings = showDeveloperSettings, showLabsItem = showLabsItem, + showLocationSettings = isLocationServiceAvailable, snackbarMessage = snackbarMessage, eventSink = ::handleEvent, ) } + + private fun unreadCountFlow(sessionId: SessionId): Flow { + val client = matrixClientProvider.getOrNull(sessionId) ?: return flowOf(0) + return client.totalUnreadCountFlow() + } } 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 67b75a84a8..bf9fb14416 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 @@ -28,10 +28,11 @@ data class PreferencesRootState( val version: String, val isMultiAccountEnabled: Boolean, val isOtherAccountsSectionExpanded: Boolean, - val otherSessions: ImmutableList, + val otherSessions: ImmutableList, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val showLabsItem: Boolean, + val showLocationSettings: Boolean, val snackbarMessage: SnackbarMessage?, val eventSink: (PreferencesRootEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 57b7493dd5..3125291c56 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider + anOtherAccount(user = user, unreadCount = unreadCount) + }, + ), ) } @@ -64,10 +74,11 @@ fun aPreferencesRootState( isOtherAccountsSectionExpanded: Boolean = false, theme: ThemeOption = ThemeOption.System, availableThemeOptions: List = ThemeOption.entries, - otherSessions: List = emptyList(), + otherSessions: List = emptyList(), showAnalyticsSettings: Boolean = false, showDeveloperSettings: Boolean = false, showLabsItem: Boolean = false, + showLocationSettings: Boolean = true, userStatusState: UserStatusState? = aUserStatusState(), snackbarMessage: SnackbarMessage? = null, eventSink: (PreferencesRootEvent) -> Unit = {}, @@ -83,7 +94,16 @@ fun aPreferencesRootState( showAnalyticsSettings = showAnalyticsSettings, showDeveloperSettings = showDeveloperSettings, showLabsItem = showLabsItem, + showLocationSettings = showLocationSettings, userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, ) + +fun anOtherAccount( + user: MatrixUser = aMatrixUser(), + unreadCount: Int = 0, +) = OtherAccount( + user = user, + unreadCount = unreadCount, +) 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 1449ece700..79aabeea2e 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 @@ -43,6 +43,7 @@ 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.atomic.atoms.CounterAtom 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.AvatarRow @@ -65,6 +66,7 @@ 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.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -79,6 +81,7 @@ fun PreferencesRootView( emojiPickerRenderer: EmojiPickerRenderer, onBackClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, onOpenAnalytics: () -> Unit, onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, @@ -124,6 +127,7 @@ fun PreferencesRootView( MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, ) } HorizontalDivider( @@ -208,6 +212,7 @@ private fun ColumnScope.UserStatusSection( private fun ColumnScope.MultiAccountSection( state: PreferencesRootState, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, ) { HorizontalDivider( thickness = 8.dp, @@ -240,7 +245,7 @@ private fun ColumnScope.MultiAccountSection( AvatarRow( avatarDataList = state.otherSessions .take(3) - .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .map { it.user.getAvatarData(AvatarSize.OtherAccountItem) } .toImmutableList(), avatarType = AvatarType.User, lastOnTop = true, @@ -271,16 +276,21 @@ private fun ColumnScope.MultiAccountSection( thickness = 1.dp, color = ElementTheme.colors.bgSubtleSecondary, ) - state.otherSessions.forEach { matrixUser -> + state.otherSessions.forEach { otherAccount -> MatrixUserRow( modifier = Modifier .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + onSwitchToSession(otherAccount.user.userId) } .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), - matrixUser = matrixUser, + matrixUser = otherAccount.user, avatarSize = AvatarSize.AccountItem, verticalSpaceWidth = 16.dp, + trailingContent = if (otherAccount.unreadCount > 0) { + { CounterAtom(count = otherAccount.unreadCount) } + } else { + null + }, ) } AddAccountItem(onAddAccountClick) @@ -330,11 +340,13 @@ private fun AppSettingsSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onOpenLockScreenSettings, ) - ListItem( - content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), - onClick = onOpenLocationSettings, - ) + if (state.showLocationSettings) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), + onClick = onOpenLocationSettings, + ) + } } } @@ -425,6 +437,7 @@ private fun ContentToPreview(state: PreferencesRootState) { emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = {}, onAddAccountClick = {}, + onSwitchToSession = {}, onOpenAnalytics = {}, onOpenDeveloperSettings = {}, onOpenLocationSettings = {}, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 89e739fe56..f632221f22 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings /** * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=3182-36115&t=U1vS3px9HzlzWYd7-4 */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditUserProfileView( state: EditUserProfileState, diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml index 5c690dd824..6ea3fc774b 100644 --- a/features/preferences/impl/src/main/res/values-cs/translations.xml +++ b/features/preferences/impl/src/main/res/values-cs/translations.xml @@ -38,8 +38,8 @@ "Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění o psaní." "Vždy skrýt" "Vždy zobrazit" - "V soukromých místnostech" - "Skryté médium lze vždy zobrazit klepnutím na něj" + "Zobrazit v soukromých místnostech" + "Skrytá média lze vždy zobrazit klepnutím na ně." "Zobrazit média na časové ose" "Povolit možnost zobrazení zdroje zprávy na časové ose." "Nemáte žádné blokované uživatele" @@ -59,6 +59,7 @@ "Máte chuť experimentovat?" "Experimentální funkce" "Další nastavení" + "Vyzváněcí tón hovoru" "Halsové a video hovory" "Neshoda konfigurace" "Zjednodušili jsme nastavení oznámení, abychom usnadnili hledání možností. @@ -77,12 +78,28 @@ Pokud budete pokračovat, některá nastavení se mohou změnit." "Konfigurace nebyla opravena, zkuste to prosím znovu." "Skupinové chaty" "Pozvánky" - "Váš domovský server tuto možnost v zašifrovaných místnostech nepodporuje, v některých místnostech nemusíte být upozorněni." + "Váš poskytovatel účtu tuto možnost v encrypted chatech nepodporuje, v některých chatech se vám proto nemusí zobrazovat oznámení." "Zmínky" + "Vyberte jiný zvuk…" + "Aktuálně používáte %1$s" + "Zvuk zprávy" + "Zvuk zprávy" "Vše" "Zmínky" "Upozornit mě na" - "Upozornit mě na @room" + "Zmínky o @room" + "Vlastní" + "Vlastní zvuk…" + "Chyba při mazání souboru" + "Výchozí nastavení Elementu" + "Element Fade" + "Chyba při importu souboru" + "Problém s náhledem zvuku upozornění" + "Zvuk" + "Zrušit chybu zvukového upozornění" + "Problém s nastavením zvuku upozornění" + "Tichý" + "Výchozí nastavení systému" "Chcete-li dostávat oznámení, změňte prosím svůj %1$s." "systémová nastavení" "Systémová oznámení byla vypnuta" diff --git a/features/preferences/impl/src/main/res/values-el/translations.xml b/features/preferences/impl/src/main/res/values-el/translations.xml index fd684eea53..13b167c172 100644 --- a/features/preferences/impl/src/main/res/values-el/translations.xml +++ b/features/preferences/impl/src/main/res/values-el/translations.xml @@ -37,7 +37,7 @@ "Εάν απενεργοποιηθεί, δεν θα μπορείς να στέλνεις ή να λαμβάνεις αποδεικτικά ανάγνωσης ή ειδοποιήσεις πληκτρολόγησης." "Πάντα απόκρυψη" "Πάντα εμφάνιση" - "Σε ιδιωτικές αίθουσες" + "Εμφάνιση σε ιδιωτικές αίθουσες" "Ένα κρυφό πολυμέσο μπορεί πάντα να εμφανιστεί πατώντας το" "Εμφάνιση πολυμέσων στο timeline" "Ενεργοποίησε την επιλογή για προβολή πηγής μηνυμάτων στη ροή." @@ -58,6 +58,7 @@ "Έχετε διάθεση για πειραματισμούς;" "Εργαστήρια" "Πρόσθετες ρυθμίσεις" + "Ήχος κλήσης" "Κλήσεις ήχου και βίντεο" "Αναντιστοιχία διαμόρφωσης" "Απλοποιήσαμε τις Ρυθμίσεις Ειδοποιήσεων για να διευκολύνουμε την εύρεση επιλογών. Ορισμένες προσαρμοσμένες ρυθμίσεις που έχετε επιλέξει στο παρελθόν δεν εμφανίζονται εδώ, αλλά εξακολουθούν να είναι ενεργές. @@ -76,14 +77,39 @@ "Προσκλήσεις" "Ο πάροχος του λογαριασμού σας δεν υποστηρίζει αυτήν την επιλογή σε κρυπτογραφημένες συνομιλίες. Ενδέχεται να μην λαμβάνετε ειδοποιήσεις σε ορισμένες συνομιλίες." "Αναφορές" + "Επιλέξτε έναν άλλο ήχο…" + "Αυτήν τη στιγμή χρησιμοποιείται το %1$s" + "Ήχος μηνύματος" + "Ήχος μηνύματος" "Όλα" "Αναφορές" "Ειδοποίησέ με για" "Αναφορές στην @αίθουσα" + "Προσαρμοσμένο" + "Προσαρμοσμένος ήχος…" + "Σφάλμα κατά τη διαγραφή αρχείου" + "Προεπιλογή του Element" + "Σβήσιμο του Element" + "Σφάλμα κατά την εισαγωγή αρχείου" + "Πρόβλημα με την προεπισκόπηση του ήχου ειδοποίησης" + "Ήχος" + "Απόρριψη σφάλματος ήχου ειδοποίησης" + "Πρόβλημα με τη ρύθμιση του ήχου ειδοποίησης" + "Σιωπηλό" + "Προεπιλογή συστήματος" "Για να λαμβάνεις ειδοποιήσεις, άλλαξε το %1$s." "ρυθμίσεις συστήματος" "Ειδοποιήσεις συστήματος ανενεργές" "Ειδοποιήσεις" + "Λείπει" + "Επιστρέφω αμέσως" + "Προσαρμοσμένη…" + "Προσαρμοσμένη κατάσταση…" + "Χρόνος εστίασης" + "Σε μια συνάντηση" + "Εν κινήσει" + "Ποια είναι η κατάστασή σας;" + "Μαύρο" "Σκοτεινό" "Φωτεινό" "Σύστημα" diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml index 98854a6ba5..d97b2af542 100644 --- a/features/preferences/impl/src/main/res/values-fr/translations.xml +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -37,8 +37,6 @@ "Si cette option est désactivée, vous ne pourrez ni envoyer ni recevoir de confirmations de lecture ni de notifications de saisie." "Toujours cacher" "Toujours montrer" - "Dans les salons privés" - "Un média caché peut toujours être affiché en cliquant dessus" "Afficher les médias dans les discussions." "Activer cette option pour pouvoir voir la source des messages dans la discussion." "Vous n’avez bloqué personne" @@ -52,11 +50,17 @@ "Impossible de mettre à jour le profil" "Modifier le profil" "Mise à jour du profil…" + "Gallerie d’images" + "Envoyez plusieurs images ou vidéos en un seul message. Fonctionne parfaitement sur mobile, mais pas encore supporté sur le web." + "Les utilisateurs peuvent demander l’accès à un salon privé. Stable sur mobile, une version web existe mais est encore expérimentale." "Activez les fils de discussion." "Un changement entraînera le redémarrage de l’application." "Découvrez nos dernières idées en cours de développement. Ces fonctionnalités ne sont pas encore finalisées; elles peuvent être instables et évoluer." "Envie d’expérimenter?" "Expérimental" + "Invitations" + "Autres utilisateurs" + "Votre apparence" "Réglages supplémentaires" "Sonnerie d’appel" "Appels audio et vidéo" @@ -85,6 +89,8 @@ Si vous continuez, il est possible que certains de vos paramètres soient modifi "Mentions" "Prévenez-moi pour" "Un message qui contient @room" + "Puce ou nombre de messages non lus" + "Afficher toutes les activités dans la liste des salons" "Personnalisé" "Son personnalisé…" "Erreur lors de la suppression du fichier" @@ -101,6 +107,7 @@ Si vous continuez, il est possible que certains de vos paramètres soient modifi "paramètres du système" "Les notifications du système sont désactivées" "Notifications" + "Ajouter un compte" "Absent" "Je reviens tout de suite" "Personnalisé…" diff --git a/features/preferences/impl/src/main/res/values-hr/translations.xml b/features/preferences/impl/src/main/res/values-hr/translations.xml index 1bec1cb75b..e20c9040db 100644 --- a/features/preferences/impl/src/main/res/values-hr/translations.xml +++ b/features/preferences/impl/src/main/res/values-hr/translations.xml @@ -38,7 +38,7 @@ "Ako je to isključeno, nećete moći slati ili primati potvrde o čitanju ili obavijesti o tipkanju." "Uvijek sakrij" "Uvijek prikaži" - "U privatnim sobama" + "Prikaži u privatnim sobama" "Skriveni medij uvijek se može prikazati tako se da se dodirne" "Prikaži medije na vremenskoj traci" "Omogući opciju za prikaz izvora poruke na vremenskoj traci." @@ -62,6 +62,9 @@ "Isprobajte naše najnovije ideje u razvoju. Ove značajke nisu finalizirane; mogu biti nestabilne i mijenjati se." "Jeste li spremni za eksperimentiranje?" "Laboratoriji" + "Pozivnice" + "Ostali korisnici" + "Tvoj izgled" "Dodatne postavke" "Ton zvona" "Audiopozivi i videopozivi" @@ -90,6 +93,8 @@ Ako nastavite, neke od vaših postavki mogu se promijeniti." "Spominjanja" "Obavijesti me za" "Obavijesti me o sobi @soba" + "Točke ili broj nepročitanih poruka" + "Prikaži sve aktivnosti na popisu soba" "Prilagođeno" "Personalizirano zvono" "Pogreška pri brisanju datoteke" @@ -106,6 +111,7 @@ Ako nastavite, neke od vaših postavki mogu se promijeniti." "postavke sustava" "Obavijesti sustava su isključene" "Obavijesti" + "Dodaj još jedan račun" "Odsutan/na" "Odmah se vraćam" "Prilagođeno" 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 193e28cff6..e72f05a776 100644 --- a/features/preferences/impl/src/main/res/values-hu/translations.xml +++ b/features/preferences/impl/src/main/res/values-hu/translations.xml @@ -37,8 +37,8 @@ "Ha ki van kapcsolva, nem tud olvasási visszaigazolást vagy írási értesítést küldeni és fogadni" "Elrejtés mindig" "Megjelenítés mindig" - "Privát szobákban" - "A rejtett médiatartalmak koppintással jeleníthetők meg" + "Megjelenítés privát szobákban" + "A rejtett médiatartalmak mindig megjeleníthetők, ha rájuk koppint." "Média megjelenítése az idővonalon" "Engedélyezze a beállítást az üzenet forrásának megjelenítéséhez az idővonalon." "Nincsenek letiltott felhasználók" @@ -61,6 +61,9 @@ "Próbálja ki legújabb fejlesztéseinket. Ezek a funkciók még nem véglegesek, instabilak lehetnek és változhatnak." "Kísérletezni szeretne?" "Kísérletek" + "Meghívók" + "Más felhasználók" + "A megjelenése" "További beállítások" "Csengőhang" "Hang- és videóhívások" @@ -89,6 +92,8 @@ Ha folytatja, egyes beállítások megváltozhatnak." "Említések" "Értesítés ezekről:" @szoba említések + "Pontok vagy olvasatlan üzenetek száma" + "Az összes tevékenység megjelenítése a szobalistában" "Egyedi" "Egyedi hang…" "Hiba a fájl törlésekor" @@ -105,6 +110,7 @@ Ha folytatja, egyes beállítások megváltozhatnak." "rendszerbeállításokat" "A rendszerértesítések ki vannak kapcsolva" "Értesítések" + "Fiók hozzáadása" "Távol" "Rögtön visszajövök" "Egyedi…" diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml index faad2bece7..379bca4fb0 100644 --- a/features/preferences/impl/src/main/res/values-in/translations.xml +++ b/features/preferences/impl/src/main/res/values-in/translations.xml @@ -85,7 +85,7 @@ Jika Anda melanjutkan, beberapa pengaturan Anda dapat berubah." "Semua" "Sebutan" "Beri tahu saya tentang" - "Beri tahu saya pada @room" + "Penyebutan @room" "Kustom" "Suara kustom" "Terjadi kesalahan saat menghapus file." 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 0e24010dba..84157e9db5 100644 --- a/features/preferences/impl/src/main/res/values-pl/translations.xml +++ b/features/preferences/impl/src/main/res/values-pl/translations.xml @@ -38,8 +38,8 @@ "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ż w pokojach prywatnych" + "Ukryte media można zawsze wyświetlić, dotykając ich." "Pokaż media na osi czasu" "Włącz, aby móc wyświetlać źródło wiadomości na osi czasu." "Nie blokujesz żadnych użytkowników" @@ -62,6 +62,9 @@ "Wypróbuj nasze najnowsze pomysły w fazie rozwoju. Funkcje te nie są jeszcze sfinalizowane; mogą być niestabilne i ulec zmianie." "Chcesz poeksperymentować?" "Laboratoria" + "Zaproszenia" + "Pozostali użytkownicy" + "Twój wygląd" "Dodatkowe ustawienia" "Dzwonek połączenia" "Połączenia audio i wideo" @@ -90,6 +93,8 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "Wzmianki" "Powiadamiaj mnie przez" "Wzmianki @room" + "Kropki lub liczba nieprzeczytanych wiadomości" + "Pokaż całą aktywność na liście pokoi" "Własny" "Własny dźwięk…" "Błąd usuwania pliku" @@ -106,6 +111,7 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "ustawienia systemowe" "Powiadomienia systemowe wyłączone" "Powiadomienia" + "Dodaj konto" "Z dala od urządzenia" "Zaraz wracam" "Własny" @@ -117,7 +123,7 @@ Niektóre ustawienia mogą ulec zmianie, jeśli kontynuujesz." "Czarny" "Ciemny" "Jasny" - "System" + "Systemowy" "Historia powiadomień Push" "Rozwiązywanie problemów" "Rozwiązywanie problemów powiadomień" 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 9c261d75fb..9b9c9bed83 100644 --- a/features/preferences/impl/src/main/res/values-ro/translations.xml +++ b/features/preferences/impl/src/main/res/values-ro/translations.xml @@ -1,24 +1,24 @@ - "Pentru a vă asigura că nu pierdeți niciodată un apel important, vă rugăm să modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat." - "Îmbunătățiți-vă experiența in timpul unui apel" - "Alegeți modul de primire a notificărilor" + "Pentru a vă asigura că nu pierdeți niciodată un apel important, modificați setările pentru a permite notificări fullscreen atunci când telefonul este blocat." + "Îmbunătățiți-vă experiența în timpul unui apel" + "Alegeți cum doriți să primiți notificări" "Modul dezvoltator" "Activați pentru a avea acces la funcționalități pentru dezvoltatori." - "Adresa URL de bază Element Call" + "URL de bază personalizat pentru Element Call" "Setați o adresă URL de bază personalizată pentru Element Call." - "URL invalid, vă rugăm să vă asigurați că includeți protocolul (http/https) și adresa corectă." + "URL invalid, asigurați-vă că includeți protocolul (http/https) și adresa corectă." "Ascundeți avatarele din invitațiile pentru camere" - "Ascundeți previzualizările media în lista de mesaje" + "Ascundeți previzualizările media în cronologie" "Laboratoare" "Distanța pe care trebuie să o parcurgeți pentru a declanșa o actualizare." - "Asigurați-vă că este activată opțiunea „Locație precisă” pentru această aplicație. Pentru a schimba permisiunea, accesați %1$s." + "Asigurați-vă că este activată opțiunea „Locație exactă” pentru această aplicație. Pentru a schimba permisiunea, accesați %1$s." "Setările aplicației" - "Actualizări in timp real ale locației" + "Actualizări în timp real ale locației" "La fiecare %1$d metru" "La fiecare %1$d metri" - "La fiecare %1$d metri" + "La fiecare %1$d de metri" "Încărcați fotografii și videoclipuri mai rapid și reduceți consumul de date" "Optimizați calitatea media" @@ -32,53 +32,58 @@ "Calitatea încărcării videoclipurilor" "Furnizor de notificări push" "Dezactivați editorul avansat pentru a tasta manual Markdown." - "Chitanțe de citire" - "Dacă dezactivată, chitanțele dumneavoastră de citire nu vor fi trimise nimănui. Veți primi în continuare chitanțe de citire de la alți utilizatori." + "Confirmări de citire" + "Dacă este dezactivată, nu veți mai trimite confirmări de citire nimănui. Veți primi în continuare confirmări de citire de la alți utilizatori." "Împărtășiți prezența" - "Dacă dezactivată, nu veți putea trimite sau primi chitanțe de citire sau notificări de tastare." - "Ascundeţi întotdeauna" + "Dacă este dezactivată, nu veți putea trimite sau primi confirmări de citire sau notificări de tastare." + "Ascundeți întotdeauna" "Afișați întotdeauna" "În camere private" - "Un fișier media ascuns poate fi afișat oricând prin apăsarea pe acesta." - "Afișați conținutul media în lista de mesaje" - "Activați opțiunea pentru a vizualiza sursa mesajelor." + "Un fișier media ascuns poate fi afișat oricând prin atingere." + "Afișați conținutul media în cronologie" + "Activați opțiunea pentru a vizualiza sursa mesajelor în cronologie." "Nu aveți utilizatori blocați" "Deblocați" - "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "După deblocare, veți putea vedea din nou toate mesajele de la aceștia." "Deblocați utilizatorul" "Se deblochează…" - "Nume" - "Numele dumneavoastra" - "A fost întâlnită o eroare necunoscută și informațiile nu au putut fi modificate." + "Nume afișat" + "Numele dvs." + "A survenit o eroare necunoscută și informațiile nu au putut fi modificate." "Nu s-a putut actualiza profilul" "Editați profilul" "Se actualizează profilul…" + "Mesaje din galerie" + "Trimiteți mai multe imagini sau videoclipuri simultan. Funcționează stabil pe dispozitivele mobile, dar nu este încă acceptat pe web." + "Solicitați să vă alăturați" + "Utilizatorii pot solicita accesul la o cameră care nu este deschisă tuturor. Funcționează stabil pe dispozitive mobile; versiunea web există, dar este încă în stadiu experimental." "Activați răspunsurile în fir" "Aplicația va reporni pentru a aplica această modificare." "Încercați cele mai noi idei în curs de dezvoltare. Aceste funcții nu sunt finalizate; pot fi instabile și pot suferi modificări." "Doriți experiențe noi?" "Laboratoare" + "Invitații" + "Alți utilizatori" + "Aspectul dvs." "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. + "Am simplificat Setările pentru notificări pentru a face opțiunile mai ușor de găsit. Unele setări personalizate pe care le-ați ales în trecut nu sunt afișate aici, dar sunt încă active. -Unele setări personalizate pe care le-ați ales în trecut nu sunt afișate aici, dar sunt încă active. - -Dacă continuați, unele dintre setările dumneavoastră pot fi modificate." - "Discuții directe" - "Setare personalizată per chat" - "A apărut o eroare în timpul actualizării setărilor pentru notificari." +În cazul în care continuați, unele dintre setările dvs. pot fi modificate." + "Conversații directe" + "Setare personalizată per conversație" + "A apărut o eroare în timpul actualizării setărilor pentru notificări." "Toate mesajele" - "Numai mențiuni și cuvinte cheie" - "În conversațiile directe, anunță-mă pentru" + "Numai mențiuni și răspunsuri" + "În conversațiile directe, anunțați-mă pentru" "În conversațiile de grup, anunțați-mă pentru" "Activați notificările pe acest dispozitiv" - "Configurația nu a fost corectată, vă rugăm să încercați din nou." - "Discuții de grup" + "Configurația nu a fost corectată, încercați din nou." + "Conversații de grup" "Invitații" - "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere." + "Furnizorul contului dvs. nu acceptă această opțiune în conversațiile criptate, este posibil să nu primiți notificări în unele conversații." "Mențiuni" "Alegeți un alt sunet…" "Se utilizează în prezent %1$s" @@ -86,37 +91,40 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate.""Sunet de mesaj" "Toate" "Mențiuni" - "Anunță-mă pentru" + "Anunțați-mă pentru" "Anunțați-mă pentru @room" + "Puncte sau numărul de mesaje necitite" + "Afișați toate activitățile din lista camerelor" "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ă" + "Eroare la importarea fișierului" + "Eroare la previzualizarea sunetului de alertă" "Sunet" "Ignorați eroarea privind sunetul de alertă" - "A fost întâmpinată o problemă la setarea sunetului de alertă" + "Eroare 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" + "Adăugați un cont" + "Plecat(ă)" "Revin imediat" "Personalizat…" "Status personalizat…" "Timp de concentrare" "Într-o ședință" "Pe drum" - "Care este statusul dumneavoastră?" + "Care este statusul dvs.?" "Negru" "Întunecat" "Deschis" "Sistem" - "Istoricul notificărilor" - "Depanare" + "Istoricul notificărilor push" + "Depanați" "Depanați notificările" diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml index c0c1383f1c..96430521d5 100644 --- a/features/preferences/impl/src/main/res/values-ru/translations.xml +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -38,8 +38,8 @@ "Если выключено, Вы не будете видеть, кто печатает и читает сообщения, а также другие пользователи не будут знать, когда Вы печатаете или читаете сообщения." "Всегда скрывать" "Всегда показывать" - "В приватных комнатах" - "Скрытые медиа всегда можно просмотреть, нажав на них." + "Показывать в приватных комнатах" + "Скрытый контент всегда можно отобразить, нажав на него." "Показать медиа в истории сообщений" "Включить опцию просмотра источника сообщения в истории сообщений." "У Вас нет заблокированных пользователей" @@ -62,6 +62,9 @@ "Попробуйте функции в разработке. Эти функции ещё не завершены, они нестабильны и могут измениться." "Хотите попробовать?" "Лаборатория" + "Приглашения" + "Другие пользователи" + "Ваш внешний вид" "Дополнительные параметры" "Мелодия звонка" "Аудио и видео звонки" diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml index a56d8d8a94..5e30b00b62 100644 --- a/features/preferences/impl/src/main/res/values-uk/translations.xml +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -38,8 +38,8 @@ "Якщо вимкнути цю функцію, ви не зможете надсилати чи отримувати підтвердження прочитання або сповіщення про набір тексту." "Завжди ховати" "Завжди показувати" - "У приватних кімнатах" - "Сховані медіа завжди можна переглянути, натиснувши на нього" + "Показувати в приватних кімнатах" + "Приховані медіафайли завжди можна переглянути, натиснувши на них." "Показувати медіа у стрічці" "Увімкнення цього параметра уможливить перегляд джерела повідомлень зі стрічки." "У вас немає заблокованих користувачів." @@ -62,6 +62,9 @@ "Випробуйте найновіші ідеї наших розробників. Ці функції ще не завершені, вони можуть бути нестабільними та змінюватись." "Хочете поекспериментувати?" "Лабораторія" + "Запрошення" + "Інші користувачі" + "Як вас бачать інші" "Додаткові налаштування" "Мелодія виклику" "Аудіо та відеовиклики" @@ -90,6 +93,8 @@ "Згадки" "Сповіщати мене про" "Згадки @room" + "Крапки або кількість непрочитаних повідомлень" + "Показати всю діяльність у списку кімнат" "Власний" "Користувацький звук…" "Помилка видалення файлу" @@ -106,6 +111,7 @@ "системні налаштування" "Системні сповіщення вимкнені" "Сповіщення" + "Додати обліковий запис" "Не на місці" "Зараз повернуся" "Власний…" 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 64292c7aac..0d57db9195 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 @@ -51,6 +51,10 @@ "無法更新個人檔案" "編輯個人檔案" "正在更新個人檔案…" + "圖庫訊息" + "一次傳送多張圖片或影片。在行動裝置上可穩定運作,但網頁端尚不支援。" + "要求加入" + "使用者可以申請加入尚未對所有人開放的聊天室。此功能在行動裝置上已穩定運作,網頁版雖已實作,但仍處於實驗階段。" "啟用討論串回覆" "應用程式將會重新啟動以套用此變更。" "試試我們正在開發中的最新構想。這些功能可能尚未完成,可能不夠穩定,也可能隨時變動。" diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml index a286c4b832..bf6bc9fa51 100644 --- a/features/preferences/impl/src/main/res/values-zh/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -36,8 +36,8 @@ "关闭后将无法发送或接收已读回执、输入通知" "始终隐藏" "始终显示" - "在私人房间" - "点击隐藏的媒体即可将其恢复显示" + "在私有房间中显示" + "被隐藏的媒体可始终通过点触将其恢复显示" "在时间线上显示媒体" "启用在时间线上查看消息源码的选项。" "暂无已屏蔽的用户" @@ -60,6 +60,9 @@ "尝试我们最新的开发理念。这些功能尚未最终确定,可能不稳定,也可能会发生变化。" "想尝试新功能?" "实验室" + "邀请" + "其他用户" + "你的形象" "更多设置" "来电铃声" "音视频通话" @@ -88,6 +91,8 @@ "提及" "通知我以下类型" "含有 @room 的提及" + "用于未读消息的圆点或数字" + "显示房间列表中的所有活动" "自定义" "自定义提示音…" "删除文件时出错" @@ -104,6 +109,7 @@ "系统设置" "系统通知已关闭" "通知" + "添加账户" "离开" "马上回来" "自定义…" diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 866cdae584..c702ac62a3 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -92,6 +92,8 @@ If you proceed, some of your settings may change." "Mentions" "Notify me for" "Mentions of @room" + "Dots or number of unread messages" + "Show all activity in the room list" "Custom" "Custom sound…" "Error deleting file" @@ -108,6 +110,7 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" + "Add account" "Away" "Be right back" "Custom…" diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index afc3801c88..80490255a6 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint @@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest { } val callback = object : PreferencesEntryPoint.Callback { override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToSecureBackup() = lambdaError() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt index cd1bd186f4..dce2d78a0d 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenterTest.kt @@ -10,7 +10,6 @@ package io.element.android.features.preferences.impl.account import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -65,7 +64,6 @@ class PreferencesAccountPresenterTest { assertThat(initialState.showSecureBackupBadge).isFalse() assertThat(initialState.accountManagementUrl).isNull() assertThat(initialState.showLinkNewDevice).isFalse() - assertThat(initialState.directLogoutState).isEqualTo(aDirectLogoutState()) assertThat(initialState.snackbarMessage).isNull() val loadedState = consumeItemsUntilPredicate { it.accountManagementUrl != null }.last() assertThat(loadedState.showSecureBackup).isFalse() @@ -169,7 +167,6 @@ class PreferencesAccountPresenterTest { sessionVerificationService = sessionVerificationService, snackbarDispatcher = SnackbarDispatcher(), indicatorService = indicatorService, - directLogoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, featureFlagService = featureFlagService, sessionEnterpriseService = sessionEnterpriseService, 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 b76939f72c..16b0761757 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 @@ -19,7 +19,11 @@ import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.data.megaBytes @@ -33,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID 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.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -66,7 +71,13 @@ class DeveloperSettingsPresenterTest { val presenter = createDeveloperSettingsPresenter( databaseSizesUseCase = GetDatabaseSizesUseCase { Result.success( - SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes) + SdkStoreSizes( + stateStore = 10.megaBytes, + eventCacheStore = 10.megaBytes, + mediaStore = 10.megaBytes, + cryptoStore = 10.megaBytes, + searchIndex = 10.megaBytes, + ) ) } ) @@ -250,6 +261,38 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - copy to clipboard on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy to clipboard on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + private fun createDeveloperSettingsPresenter( navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() }, sessionId: SessionId = A_SESSION_ID, @@ -258,10 +301,12 @@ class DeveloperSettingsPresenterTest { clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), enterpriseService: EnterpriseService = FakeEnterpriseService(), vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, - databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, + databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null, null)) }, markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), buildMeta: BuildMeta = aBuildMeta(), notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( navigator = navigator, @@ -277,6 +322,8 @@ class DeveloperSettingsPresenterTest { markAllRoomsAsRead = markAllRoomsAsRead, buildMeta = buildMeta, notificationSettingsService = notificationSettingsService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } 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 9300236293..3ec56a64fd 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 @@ -12,12 +12,17 @@ package io.element.android.features.preferences.impl.developer.appsettings import com.google.common.truth.Truth.assertThat import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper 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.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -151,6 +156,38 @@ class AppDeveloperSettingsPresenterTest { } } + @Test + fun `present - copy to clipboard on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createAppDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy to clipboard on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createAppDeveloperSettingsPresenter( + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.test { + awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("text") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + private fun createAppDeveloperSettingsPresenter( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> @@ -164,6 +201,8 @@ class AppDeveloperSettingsPresenterTest { } ), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): AppDeveloperSettingsPresenter { return AppDeveloperSettingsPresenter( featureFlagService = featureFlagService, @@ -172,7 +211,9 @@ class AppDeveloperSettingsPresenterTest { buildMeta = aBuildMeta( gitRevision = "1234567890", gitBranchName = "feature/awesome-feature" - ) + ), + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index 8b32ef993c..ed65f9a9f3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -10,6 +10,9 @@ package io.element.android.features.preferences.impl.notifications import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.ShowAllActivityInRoomListFeature +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.matrix.api.MatrixClient @@ -37,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope @@ -1104,6 +1108,46 @@ class NotificationSettingsPresenterTest { } } + @Test + fun `present - showAllActivityInRoomList reflects the feature flag value`() = runTest { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(ShowAllActivityInRoomListFeature.key to true) + ) + val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService) + presenter.test { + val state = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last() + assertThat(state.showAllActivityInRoomList).isTrue() + featureFlagService.setFeatureEnabled(ShowAllActivityInRoomListFeature, false) + + val updatedState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last() + assertThat(updatedState.showAllActivityInRoomList).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - ToggleShowAllActivityInRoomList toggles the feature flag`() = runTest { + val featureFlagService = FakeFeatureFlagService( + initialState = mapOf(ShowAllActivityInRoomListFeature.key to false) + ) + val presenter = createNotificationSettingsPresenter(featureFlagService = featureFlagService) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.showAllActivityInRoomList).isFalse() + initialState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList(true)) + + val enabledState = consumeItemsUntilPredicate { it.showAllActivityInRoomList }.last() + assertThat(enabledState.showAllActivityInRoomList).isTrue() + assertThat(featureFlagService.isFeatureEnabled(ShowAllActivityInRoomListFeature)).isTrue() + enabledState.eventSink(NotificationSettingsEvent.ToggleShowAllActivityInRoomList(false)) + + val disabledState = consumeItemsUntilPredicate { !it.showAllActivityInRoomList }.last() + assertThat(disabledState.showAllActivityInRoomList).isFalse() + assertThat(featureFlagService.isFeatureEnabled(ShowAllActivityInRoomListFeature)).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + private fun TestScope.createNotificationSettingsPresenter( notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), pushService: PushService = FakePushService(), @@ -1116,6 +1160,7 @@ class NotificationSettingsPresenterTest { NotificationSoundCopier.CopyResult.Success(source, source) }), soundDisplayNameResolver: FakeSoundDisplayNameResolver = FakeSoundDisplayNameResolver(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), ): NotificationSettingsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) return NotificationSettingsPresenter( @@ -1131,6 +1176,8 @@ class NotificationSettingsPresenterTest { soundDisplayNameResolver = soundDisplayNameResolver, stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, + featureFlagService = featureFlagService, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index a28f43638f..10510d1172 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -183,6 +183,35 @@ class NotificationSettingsViewTest : RobolectricTest() { ) } + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on disable show all activity emits the expected events`() { + testShowAllActivityToggle(true) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on enable show all activity emits the expected events`() { + testShowAllActivityToggle(false) + } + + private fun testShowAllActivityToggle(initialState: Boolean) = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setNotificationSettingsView( + state = aValidNotificationSettingsState( + showAllActivityInRoomList = initialState, + eventSink = eventsRecorder + ), + ) + clickOn(R.string.screen_notification_settings_show_all_activity_title) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvent.RefreshSystemNotificationsEnabled, + NotificationSettingsEvent.ToggleShowAllActivityInRoomList(!initialState), + ) + ) + } + @Config(qualifiers = "h1024dp") @Test fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest { diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 3d438f7044..57b85af096 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -13,6 +13,8 @@ package io.element.android.features.preferences.impl.root import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.compound.theme.Theme +import io.element.android.features.location.api.LocationService +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.preferences.impl.account.aPreferencesAccountState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -22,13 +24,18 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL +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.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore @@ -37,8 +44,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData 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.lambdaRecorder -import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -78,10 +83,21 @@ class PreferencesRootPresenterTest { assertThat(loadedState.showAnalyticsSettings).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.showLabsItem).isFalse() + assertThat(loadedState.showLocationSettings).isTrue() assertThat(loadedState.snackbarMessage).isNull() } } + @Test + fun `present - location settings are hidden when the location service is not available`() = runTest { + createPreferencesRootPresenter( + locationService = FakeLocationService(isServiceAvailable = false), + ).test { + assertThat(awaitItem().showLocationSettings).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - user status state is null when the server does not support it`() = runTest { createPreferencesRootPresenter( @@ -166,24 +182,6 @@ class PreferencesRootPresenterTest { } } - @Test - fun `present - switch session invoke method on the session store`() = runTest { - val setLatestSessionResult = lambdaRecorder { } - val sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData(sessionId = A_SESSION_ID.value), - aSessionData(sessionId = A_SESSION_ID_2.value), - ), - setLatestSessionResult = setLatestSessionResult, - ) - createPreferencesRootPresenter(sessionStore = sessionStore).test { - val loadedState = awaitFirstItem() - loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) - setLatestSessionResult.assertions().isCalledOnce() - .with(value(A_SESSION_ID_2.value)) - } - } - @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { createPreferencesRootPresenter( @@ -234,6 +232,7 @@ class PreferencesRootPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -241,7 +240,60 @@ class PreferencesRootPresenterTest { val state = awaitFirstItem() assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.otherSessions).hasSize(1) - assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl")) + assertThat(state.otherSessions[0]).isEqualTo( + OtherAccount( + user = MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ), + unreadCount = 0, + ) + ) + } + } + + @Test + fun `present - other accounts keep the session store order and get their unread count`() = runTest { + var bobUnreadCount = 3L + val bobSyncService = FakeSyncService() + val bobClient = FakeMatrixClient( + sessionId = A_SESSION_ID_2, + syncService = bobSyncService, + totalUnreadCountResult = { Result.success(bobUnreadCount) }, + ) + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID_3.value), + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + when (sessionId) { + A_SESSION_ID_2 -> Result.success(bobClient) + // Client for the third account is not in memory + else -> Result.failure(AN_EXCEPTION) + } + }, + ).test { + val state = consumeItemsUntilPredicate { it.otherSessions.isNotEmpty() }.last() + assertThat(state.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly( + A_USER_ID_3 to 0, + A_SESSION_ID_2 to 3, + ).inOrder() + // The unread count is refreshed on sync heartbeat + bobUnreadCount = 5 + bobSyncService.emitHeartBeat() + val updatedState = consumeItemsUntilPredicate { state -> state.otherSessions.any { it.unreadCount == 5 } }.last() + assertThat(updatedState.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly( + A_USER_ID_3 to 0, + A_SESSION_ID_2 to 5, + ).inOrder() + cancelAndIgnoreRemainingEvents() } } @@ -283,6 +335,8 @@ class PreferencesRootPresenterTest { featureFlagService: FeatureFlagService = FakeFeatureFlagService(), sessionStore: SessionStore = InMemorySessionStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + locationService: LocationService = FakeLocationService(isServiceAvailable = true), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), ) = PreferencesRootPresenter( matrixClient = matrixClient, analyticsService = FakeAnalyticsService(), @@ -290,7 +344,9 @@ class PreferencesRootPresenterTest { snackbarDispatcher = SnackbarDispatcher(), showDeveloperSettingsProvider = showDeveloperSettingsProvider, featureFlagService = featureFlagService, + locationService = locationService, sessionStore = sessionStore, + matrixClientProvider = matrixClientProvider, appPreferencesStore = appPreferencesStore, userStatusPresenter = { aUserStatusState() }, preferencesAccountPresenter = { aPreferencesAccountState() }, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 3afd6cde9d..224068a566 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -20,6 +20,7 @@ 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.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -69,23 +70,49 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() + fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(A_USER_ID_2) { callback -> + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), + ) + ), + eventSink = eventsRecorder, + ), + onSwitchToSession = callback, + ) + onNodeWithText("Bob").performClick() + } + } + + @Test + fun `unread count of other session is displayed`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) setView( aPreferencesRootState( isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = true, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), + unreadCount = 42, ) ), eventSink = eventsRecorder, ), ) - onNodeWithText("Bob").performClick() - eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) + onNodeWithText("42").assertExists() } @Test @@ -96,9 +123,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = false, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -116,9 +145,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = false, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -138,9 +169,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = true, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -395,6 +428,7 @@ private fun AndroidComposeUiTest.setView( state: PreferencesRootState, onBackClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(), + onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(), onOpenAnalytics: () -> Unit = EnsureNeverCalled(), onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), onOpenAbout: () -> Unit = EnsureNeverCalled(), @@ -418,6 +452,7 @@ private fun AndroidComposeUiTest.setView( emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = onBackClick, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, onOpenAnalytics = onOpenAnalytics, onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, diff --git a/features/rageshake/api/src/main/res/values-ro/translations.xml b/features/rageshake/api/src/main/res/values-ro/translations.xml index 67a72e3d55..ce9feadf5c 100644 --- a/features/rageshake/api/src/main/res/values-ro/translations.xml +++ b/features/rageshake/api/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ - "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?" - "Se pare că scuturați telefonul de frustrare. Doriți să deschdeți ecranul de raportare a unei erori?" + "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport de blocare?" + "Se pare că scuturați telefonul de frustrare. Doriți să deschideți ecranul de raportare a unei erori?" "Rageshake" "Prag de detecție" diff --git a/features/rageshake/impl/build.gradle.kts b/features/rageshake/impl/build.gradle.kts index 0200f21e61..578f86a39f 100644 --- a/features/rageshake/impl/build.gradle.kts +++ b/features/rageshake/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.appconfig) implementation(projects.features.enterprise.api) implementation(projects.features.viewfolder.api) - implementation(projects.services.toolbox.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.network) @@ -52,9 +51,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) - testImplementation(projects.features.rageshake.test) testImplementation(projects.features.viewfolder.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.services.toolbox.test) testImplementation(libs.network.mockwebserver) } diff --git a/features/rageshake/impl/src/main/res/values-cs/translations.xml b/features/rageshake/impl/src/main/res/values-cs/translations.xml index 93db5427d5..9b8bbdeb94 100644 --- a/features/rageshake/impl/src/main/res/values-cs/translations.xml +++ b/features/rageshake/impl/src/main/res/values-cs/translations.xml @@ -8,6 +8,8 @@ "Popište chybu…" "Pokud je to možné, prosím, napište popis anglicky." "Popis je příliš krátký, uveďte prosím více podrobností o tom, co se stalo. Děkujeme!" + "Můžete zadat číslo souvisejícího problému na GitHubu, pokud existuje." + "Issue na GitHubu" "Odeslat záznamy o selhání" "Povolit protokoly" "Vaše protokoly jsou příliš velké, proto je nelze zahrnout do této zprávy. Zašlete nám je prosím jiným způsobem." diff --git a/features/rageshake/impl/src/main/res/values-el/translations.xml b/features/rageshake/impl/src/main/res/values-el/translations.xml index 62c794afed..e62de350b0 100644 --- a/features/rageshake/impl/src/main/res/values-el/translations.xml +++ b/features/rageshake/impl/src/main/res/values-el/translations.xml @@ -8,6 +8,8 @@ "Περιέγραψε το πρόβλημα…" "Εάν είναι δυνατόν, γράψε την περιγραφή στα αγγλικά." "Η περιγραφή είναι πολύ σύντομη, δώσε περισσότερες λεπτομέρειες σχετικά με το τί συνέβη. Ευχαριστώ!" + "Μπορείτε να εισαγάγετε τον αριθμό ενός σχετικού προβλήματος GitHub, εάν υπάρχει." + "Ζήτημα GitHub" "Αποστολή αρχείων καταγραφής σφαλμάτων" "Να επιτρέπονται τα αρχεία καταγραφής" "Τα αρχεία καταγραφής σας είναι υπερβολικά μεγάλα και δεν μπορούν να συμπεριληφθούν σε αυτήν την αναφορά. Παρακαλούμε να μας τα στείλετε με άλλο τρόπο." 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 cdcfbdfbc8..4a9ad79828 100644 --- a/features/rageshake/impl/src/main/res/values-ro/translations.xml +++ b/features/rageshake/impl/src/main/res/values-ro/translations.xml @@ -1,22 +1,22 @@ "Atașați o captură de ecran" - "Puteți să mă contactați dacă aveți întrebări suplimentare" + "Puteți să mă contactați dacă aveți întrebări suplimentare." "Contactați-mă" "Editați captura de ecran" - "Vă rugăm să descrieți problema. Ce ați făcut? Ce vă aşteptați să se întâmple? Ce s-a întâmplat de fapt. Vă rugam să oferiți cât mai multe detalii cu putință." + "Vă rugăm să descrieți problema. Ce ați făcut? Ce vă așteptați să se întâmple? Ce s-a întâmplat de fapt? Oferiți cât mai multe detalii." "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!" + "Pe când posibil, vă rugăm să scrieți descrierea în engleză." + "Descrierea este prea scurtă, 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ă." + "Trimiteți jurnalele de blocări" + "Permiteți jurnale" + "Jurnalele dvs. sunt prea mari și nu pot fi incluse în acest raport. Vă rugăm să ni le trimiteți prin altă metodă." "Trimiteți captură de ecran" "Pentru a verifica că lucrurile funcționează conform așteptărilor, log-uri vor fi trimise împreună cu mesajul. Acestea vor fi private. Pentru a trimite doar mesajul, dezactivați această setare." - "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport?" + "%1$s s-a blocat ultima dată când a fost folosit. Doriți să ne trimiteți un raport de blocare?" "Dacă întâmpinați probleme cu notificările, încărcarea setărilor notificărilor ne poate ajuta să identificăm cauza principală." "Trimiteți setările notificărilor" - "Vizualizați log-urile" + "Vizualizați jurnalele" diff --git a/features/rageshake/test/build.gradle.kts b/features/rageshake/test/build.gradle.kts index faad812d73..516562c556 100644 --- a/features/rageshake/test/build.gradle.kts +++ b/features/rageshake/test/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.features.rageshake.api) - implementation(libs.coroutines.core) implementation(projects.tests.testutils) } diff --git a/features/reportroom/impl/build.gradle.kts b/features/reportroom/impl/build.gradle.kts index c945465aec..5a25ba5051 100644 --- a/features/reportroom/impl/build.gradle.kts +++ b/features/reportroom/impl/build.gradle.kts @@ -27,10 +27,8 @@ setupDependencyInjection() dependencies { api(projects.features.reportroom.api) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) 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 df67b44b22..ec463406a1 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager @@ -40,7 +39,6 @@ import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportRoomView( state: ReportRoomState, diff --git a/features/reportroom/impl/src/main/res/values-cs/translations.xml b/features/reportroom/impl/src/main/res/values-cs/translations.xml index 809cfb68a5..d4492397d2 100644 --- a/features/reportroom/impl/src/main/res/values-cs/translations.xml +++ b/features/reportroom/impl/src/main/res/values-cs/translations.xml @@ -2,7 +2,7 @@ "Vaše hlášení bylo úspěšně odesláno, ale při pokusu o opuštění místnosti jsme narazili na problém. Zkuste to prosím znovu." "Nelze opustit místnost" - "Nahlaste tuto místnost svému administrátorovi. Pokud jsou zprávy zašifrované, váš administrátor je nebude moci číst." + "Nahlaste tento chat poskytovateli vašeho účtu. Pokud jsou zprávy šifrované, poskytovatel vašeho účtu si je nebude moci přečíst." "Popište důvod…" "Nahlásit místnost" diff --git a/features/reportroom/impl/src/main/res/values-ro/translations.xml b/features/reportroom/impl/src/main/res/values-ro/translations.xml index c96d1707ab..869373d574 100644 --- a/features/reportroom/impl/src/main/res/values-ro/translations.xml +++ b/features/reportroom/impl/src/main/res/values-ro/translations.xml @@ -1,8 +1,8 @@ - "Raportul dumneavoastră a fost trimis cu succes, dar am întâmpinat o problemă în timp ce încercam să părăsim camera. Vă rugăm să încercați din nou." + "Raportul dvs. a fost trimis cu succes, dar am întâmpinat o problemă în timp ce se încerca părăsirea camerei. Încercați din nou." "Nu s-a putut părăsi camera" - "Raportați această cameră administratorului. Dacă mesaje sunt criptate, administratorul nu le va putea citi." + "Raportați această conversație furnizorului de cont. Dacă mesaje sunt criptate, acesta nu le va putea citi." "Descrieți motivul raportării…" "Raportați camera" diff --git a/features/rolesandpermissions/api/build.gradle.kts b/features/rolesandpermissions/api/build.gradle.kts index ca29972606..6ea7080314 100644 --- a/features/rolesandpermissions/api/build.gradle.kts +++ b/features/rolesandpermissions/api/build.gradle.kts @@ -17,7 +17,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) - api(projects.libraries.usersearch.api) } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt index aba7a671a8..d97ecc2094 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsView.kt @@ -12,7 +12,6 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -31,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChangeRoomPermissionsView( state: ChangeRoomPermissionsState, diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt index 05bb77dfd4..c50c38df2e 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesView.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,7 +71,6 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChangeRolesView( state: ChangeRolesState, diff --git a/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml index fcc1abe283..4585d1988e 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-el/translations.xml @@ -6,6 +6,7 @@ "Αφαίρεση μηνυμάτων" "Μέλος" "Πρόσκληση ατόμων" + "Μοιραστείτε τη τοποθεσία σας σε πραγματικό χρόνο" "Διαχείριση χώρου" "Διαχείριση αίθουσών" "Διαχείριση μελών" diff --git a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml index 962f8b2d4d..49954180d5 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml @@ -13,18 +13,18 @@ "Mesaje și conținut" "Moderator" "Îndepărtați persoane" - "Schimbați avatarul camerei" + "Schimbați avatarul" "Editați detaliile" - "Schimbă numele camerei" - "Schimbați subiectul camerei" + "Schimbați numele" + "Schimbați subiectul" "Trimiteți mesaje" "Permisiuni" "Editați administratorii" - "Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune." - "Adăugați administrator?" - "Nu veți putea anula această acțiune. Transferați dreptul de proprietate către utilizatorii selectați. Odată ce părăsiți această pagină, acțiunea va fi definitivă." + "Promovați utilizatorul la același nivel de putere ca dvs. Nu veți putea anula această acțiune." + "Adăugați un administrator?" + "Nu veți putea anula această acțiune. Transferați proprietatea către utilizatorii selectați. Odată ce părăsiți această pagină, acțiunea va fi definitivă." "Transferați proprietatea?" - "Retrogradare" + "Retrogradați" "Nu veți putea anula această modificare, deoarece vă retrogradați. Dacă sunteți ultimul utilizator privilegiat din cameră, va fi imposibil să recâștigați privilegiile." "Vreți să vă retrogradați?" "%1$s (În așteptare)" @@ -45,17 +45,17 @@ "%1$d Interziși" "Verificați ortografia sau încercați o căutare nouă" - "Niciun rezultat pentru “%1$s”" + "Niciun rezultat pentru „%1$s”" "%1$d persoană" "%1$d persoane" - "%1$d persoane" + "%1$d de persoane" - "Îndepărtați și interziceți membrul" + "Interziceți membrul" "Doar înlăturare" "Anulare excludere" - "Se vor putea alătura din nou acestei săli dacă sunt invitați." - "Revocati excluderea din camera" + "Se va putea alătura din nou acestei camere dacă primește invitație." + "Revocați excluderea din cameră" "Excluși" "Membri" @@ -68,12 +68,12 @@ "Moderator" "Proprietar" "Membrii camerei" - "Se anulează interzicerea lui %1$s" + "Se anulează interzicerea pentru %1$s" "Administratori" "Administratori și proprietari" - "Schimbare rol" - "Degradare la membru" - "Degradare la moderator" + "Schimbați-mi rolul" + "Retrogradați la membru" + "Retrogradați la moderator" "Moderarea membrilor" "Mesaje și conținut" "Moderatori" @@ -84,6 +84,6 @@ "Resetați permisiunile?" "Roluri" "Detaliile camerei" - "Detalii spațiu" + "Detaliile spațiului" "Roluri și permisiuni" diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt similarity index 93% rename from features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt rename to features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt index 0526afc938..d50d872a1d 100644 --- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeChangeRoomMemberRolesEntryPoint.kt +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeChangeRoomMemberRolesEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.changeroommemberroles.test +package io.element.android.features.rolesandpermissions.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt similarity index 91% rename from features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt rename to features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt index 62cf3285f3..00d9059a3d 100644 --- a/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt +++ b/features/rolesandpermissions/test/src/main/kotlin/io/element/android/features/rolesandpermissions/test/FakeRolesAndPermissionsEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.changeroommemberroles.test +package io.element.android.features.rolesandpermissions.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt similarity index 94% rename from features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt rename to features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt index ce0270033c..a2a1d9c163 100644 --- a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasresolver/api/RoomAliasResolverEntryPoint.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomaliasesolver.api +package io.element.android.features.roomaliasresolver.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node diff --git a/features/roomaliasresolver/impl/build.gradle.kts b/features/roomaliasresolver/impl/build.gradle.kts index 1860c77017..0c5e1c2b07 100644 --- a/features/roomaliasresolver/impl/build.gradle.kts +++ b/features/roomaliasresolver/impl/build.gradle.kts @@ -27,11 +27,9 @@ setupDependencyInjection() dependencies { api(projects.features.roomaliasresolver.api) - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt index c6f8966131..2eb83a1382 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPoint.kt @@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.createNode @ContributesBinding(AppScope::class) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt index 041d43cb26..bff41efb82 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt @@ -16,7 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt index bf339d7592..84326bd2a5 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -132,7 +131,6 @@ private fun RoomAliasResolverContent( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomAliasResolverTopBar( onBackClick: () -> Unit, diff --git a/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml index 195a947871..1e7e0e53e4 100644 --- a/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml +++ b/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,5 @@ "Nu am putut afișa previzualizarea acestei camere." - "Nu s-a putut rezolva alias-ul camerei." + "Nu s-a putut rezolva aliasul camerei." diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt index a519032ed2..f261e56adc 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/DefaultRoomAliasResolverEntryPointTest.kt @@ -11,7 +11,7 @@ package io.element.android.features.roomaliasresolver.impl 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.roomaliasesolver.api.RoomAliasResolverEntryPoint +import io.element.android.features.roomaliasresolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/roomcall/api/build.gradle.kts b/features/roomcall/api/build.gradle.kts index 54bb7bbd02..1220f87a25 100644 --- a/features/roomcall/api/build.gradle.kts +++ b/features/roomcall/api/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(libs.androidx.compose.ui.tooling.preview) } diff --git a/features/roomcall/impl/build.gradle.kts b/features/roomcall/impl/build.gradle.kts index 069c890874..20f4eab571 100644 --- a/features/roomcall/impl/build.gradle.kts +++ b/features/roomcall/impl/build.gradle.kts @@ -26,7 +26,6 @@ dependencies { implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) 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 7763933a29..d507f2c9e3 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 @@ -61,7 +61,7 @@ class RoomCallStatePresenter( isAudioCall = roomInfo.activeCallIntentConsensus.isAudio(), ) else -> { - val isEmptyDm = roomInfo.isDm && roomInfo.activeMembersCount <= 1 + val isEmptyDm = roomInfo.isDm && roomInfo.joinedMembersCount <= 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 9a4c3b0af0..5b0626279f 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 @@ -85,6 +85,21 @@ class RoomCallStatePresenterTest { } } + @Test + fun `present - a DM whose other member is only invited cannot be called`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + initialRoomInfo = aRoomInfo(isDm = true, activeMembersCount = 2, 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 - element call not available`() = runTest { val room = FakeJoinedRoom( @@ -128,7 +143,7 @@ class RoomCallStatePresenterTest { fun `present - initial state - when is DM room`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - initialRoomInfo = aRoomInfo(isDm = true), + initialRoomInfo = aRoomInfo(isDm = true, joinedMembersCount = 2), roomPermissions = roomPermissions(true), ) ) diff --git a/features/roomdetails/api/build.gradle.kts b/features/roomdetails/api/build.gradle.kts index ce393840f8..e6f6dd4f5e 100644 --- a/features/roomdetails/api/build.gradle.kts +++ b/features/roomdetails/api/build.gradle.kts @@ -7,7 +7,7 @@ */ plugins { - id("io.element.android-library") + id("io.element.android-compose-library") id("kotlin-parcelize") } diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsExtension.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsExtension.kt new file mode 100644 index 0000000000..25f8f9e1f7 --- /dev/null +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsExtension.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.features.roomdetails.api + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** Optional room-details sections contributed by a build-specific implementation. */ +fun interface RoomDetailsExtension { + @Composable + fun ColumnScope.Render(modifier: Modifier) +} diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 77e2d0c229..b8aa939015 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -34,20 +34,13 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.mediapickers.api) - implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) - implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.push.api) implementation(projects.libraries.testtags) api(projects.features.roomdetails.api) - api(projects.libraries.usersearch.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) implementation(projects.features.call.api) - implementation(projects.features.startchat.api) implementation(projects.features.leaveroom.api) implementation(projects.features.userprofile.shared) implementation(projects.services.analytics.compose) @@ -65,14 +58,9 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) - testImplementation(projects.libraries.usersearch.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.rolesandpermissions.test) testImplementation(projects.features.securityandprivacy.test) @@ -81,7 +69,6 @@ dependencies { testImplementation(projects.features.messages.test) testImplementation(projects.features.poll.test) testImplementation(projects.features.reportroom.test) - testImplementation(projects.features.startchat.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.services.analytics.test) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtension.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtension.kt new file mode 100644 index 0000000000..59178e0d09 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtension.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.roomdetails.impl + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.roomdetails.api.RoomDetailsExtension +import io.element.android.libraries.di.RoomScope + +@ContributesBinding(RoomScope::class) +class NoopRoomDetailsExtension : RoomDetailsExtension { + @Composable + override fun ColumnScope.Render(modifier: Modifier) = Unit +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index a82766175d..9d06db371f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -13,6 +13,7 @@ sealed interface RoomDetailsEvent { data object MuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent data class CopyToClipboard(val text: String) : RoomDetailsEvent + data object CopyRoomId : RoomDetailsEvent data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent data object MarkAsRead : RoomDetailsEvent data object MarkAsUnread : RoomDetailsEvent diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 7f22c7e111..dd1c10ae82 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -24,6 +24,7 @@ import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode import io.element.android.features.leaveroom.api.LeaveRoomRenderer +import io.element.android.features.roomdetails.api.RoomDetailsExtension import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.callback @@ -46,6 +47,7 @@ class RoomDetailsNode( private val room: BaseRoom, private val analyticsService: AnalyticsService, private val leaveRoomRenderer: LeaveRoomRenderer, + private val roomDetailsExtension: RoomDetailsExtension, ) : Node(buildContext, plugins = plugins), RoomDetailsNavigator { interface Callback : Plugin { fun navigateBack() @@ -120,6 +122,7 @@ class RoomDetailsNode( RoomDetailsView( state = state, + additionalSections = { with(roomDetailsExtension) { Render(Modifier) } }, modifier = modifier, goBack = ::navigateUp, onActionClick = ::onActionClick, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 9a602d06c0..e8a8079a54 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -32,6 +32,7 @@ import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermission import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher @@ -74,6 +75,7 @@ class RoomDetailsPresenter( private val dispatchers: CoroutineDispatchers, private val analyticsService: AnalyticsService, private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, private val notificationCleaner: NotificationCleaner, @@ -167,8 +169,14 @@ class RoomDetailsPresenter( } is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) is RoomDetailsEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.text) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.text) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } + } + is RoomDetailsEvent.CopyRoomId -> { + clipboardHelper.copyPlainText(room.roomId.value) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } } is RoomDetailsEvent.MarkAsRead -> scope.markAsRead() is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread() 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 b2874d01f9..626977007e 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets @@ -24,7 +25,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -48,7 +47,6 @@ import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserSection -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule @@ -124,6 +122,7 @@ fun RoomDetailsView( onProfileClick: (UserId) -> Unit, onReportRoomClick: () -> Unit, modifier: Modifier = Modifier, + additionalSections: @Composable ColumnScope.() -> Unit = {}, leaveRoomView: @Composable () -> Unit, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -199,6 +198,8 @@ fun RoomDetailsView( ) } + additionalSections() + PreferenceCategory { if (state.hasNewContent) { ListItem( @@ -336,6 +337,7 @@ fun RoomDetailsView( DebugInfoSection( roomId = state.roomId, roomVersion = state.roomVersion, + onCopyRoomIdClick = { state.eventSink(RoomDetailsEvent.CopyRoomId) }, ) } } @@ -356,7 +358,6 @@ private fun KnockRequestsItem(knockRequestsCount: Int?, onKnockRequestsClick: () ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDetailsTopBar( goBack: () -> Unit, @@ -863,10 +864,9 @@ private fun OtherActionsSection( private fun DebugInfoSection( roomId: RoomId, roomVersion: String?, + onCopyRoomIdClick: () -> Unit, ) { - val context = LocalContext.current PreferenceCategory { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) ListItem( content = { Text("Internal room ID") @@ -880,12 +880,7 @@ private fun DebugInfoSection( }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), - onClick = { - context.copyToClipboard( - text = roomId.value, - toastMessage = toastMessage, - ) - }, + onClick = onCopyRoomIdClick, ) ListItem( content = { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt index ba9d2b4498..5b9d5630bd 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets 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.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -76,7 +75,6 @@ fun RoomInviteMembersView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomInviteMembersTopBar( canSend: Boolean, 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 f19ee95106..c7b66df00c 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 @@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -352,7 +351,6 @@ private fun RoomMemberListItem( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomMemberListTopBar( canInvite: Boolean, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index 85eca4fbae..6428bd097a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -118,8 +118,9 @@ class RoomMemberDetailsPresenter( encryptionService.withdrawVerification(roomMemberId) } is UserProfileEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.text) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.text) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } else -> userProfileState.eventSink(event) } 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 f0676f133e..877f356693 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -160,7 +159,6 @@ private fun RoomSpecificNotificationSettingsView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomNotificationSettingsTopBar( onBackClick: () -> Unit, 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 befbc9382f..ebdce9330a 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -90,7 +89,6 @@ fun UserDefinedRoomNotificationSettingsView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun UserDefinedRoomNotificationSettingsTopBar( roomName: String, diff --git a/features/roomdetails/impl/src/main/res/values-cs/translations.xml b/features/roomdetails/impl/src/main/res/values-cs/translations.xml index 190e1ae5de..137bd2c8b8 100644 --- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -6,7 +6,7 @@ "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." "Upravit adresu" "Při aktualizaci nastavení oznámení došlo k chybě." - "Váš domovský server tuto možnost v zašifrovaných místnostech nepodporuje, v některých místnostech nemusíte být upozorněni." + "Váš poskytovatel účtu tuto možnost v encrypted chatech nepodporuje, v některých chatech se vám proto nemusí zobrazovat oznámení." "Hlasování" "Správce" "Vykázat lidi" @@ -115,9 +115,9 @@ "Při načítání nastavení oznámení došlo k chybě." "Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu." "Nastavení režimu se nezdařilo, zkuste to prosím znovu." - "Váš domovský server tuto možnost nepodporuje v šifrovaných místnostech, v této místnosti nebudete dostávat upozornění." + "Váš poskytovatel účtu tuto možnost v encrypted chatech nepodporuje, v tomto chatu tedy nebudete dostávat oznámení." "Všechny zprávy" - "Pouze zmínky a klíčová slova" + "Pouze zmínky a odpovědi" "V této místnosti mě upozornit na" "Správci" "Správci a vlastníci" @@ -150,7 +150,7 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Jakmile je povoleno, šifrování nelze zakázat." "Šifrování" "Povolit koncové šifrování" - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Kdokoli" "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s" "Spravovat prostory" diff --git a/features/roomdetails/impl/src/main/res/values-el/translations.xml b/features/roomdetails/impl/src/main/res/values-el/translations.xml index 8d89308c51..0573590d3d 100644 --- a/features/roomdetails/impl/src/main/res/values-el/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml @@ -147,7 +147,7 @@ "Μόλις ενεργοποιηθεί, η κρυπτογράφηση δεν μπορεί να απενεργοποιηθεί." "Κρυπτογράφηση" "Ενεργοποίηση κρυπτογράφησης από άκρο σε άκρο" - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας." "Οποιοσδήποτε" "Επιλέξτε ποια μέλη των χώρων μπορούν να συμμετάσχουν σε αυτήν την αίθουσα χωρίς πρόσκληση. %1$s" "Διαχείριση χώρων" diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index f22b81de46..8c4379b2c9 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -42,6 +42,9 @@ "Vous avez des modifications non-enregistrées." "Enregistrer les changements ?" "Ajouter un sujet" + "Appuyer pour parler" + "Audio du salon" + "Appels vidéo" "Chiffré" "Non chiffré" "Salon public" @@ -67,6 +70,9 @@ "Profil" "Demandes en attente" "Rôles & autorisations" + "Appuyer pour parler" + "Audio du salon" + "Appels vidéo" "Nom" "Sécurité & confidentialité" "Sécurité" diff --git a/features/roomdetails/impl/src/main/res/values-hr/translations.xml b/features/roomdetails/impl/src/main/res/values-hr/translations.xml index 514ac58a61..87f7ee7d64 100644 --- a/features/roomdetails/impl/src/main/res/values-hr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hr/translations.xml @@ -42,6 +42,9 @@ "Niste spremili sve promjene." "Želite li spremiti promjene?" "Dodaj temu" + "Pritisni za razgovor" + "Zvuk u sobi" + "Videopozivi" "Šifrirano" "Nije šifrirano" "Javna soba" @@ -67,6 +70,9 @@ "Profil" "Zahtjevi za pridruživanje" "Uloge i dopuštenja" + "Pritisni za razgovor" + "Zvuk u sobi" + "Videopozivi" "Naziv" "Sigurnost i privatnost" "Sigurnost" diff --git a/features/roomdetails/impl/src/main/res/values-hu/translations.xml b/features/roomdetails/impl/src/main/res/values-hu/translations.xml index d5b4ca79d8..931253fbbd 100644 --- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml @@ -42,6 +42,9 @@ "Mentetlen módosításai vannak." "Menti a módosításokat?" "Téma hozzáadása" + "Adó-vevő" + "Hang szoba" + "Videohívások" "Titkosított" "Nem titkosított" "Nyilvános szoba" @@ -67,6 +70,9 @@ "Profil" "Csatlakozási kérelem" "Szerepkörök és jogosultságok" + "Adó-vevő" + "Hang szoba" + "Videohívások" "Név" "Biztonság és adatvédelem" "Biztonság" diff --git a/features/roomdetails/impl/src/main/res/values-lt/translations.xml b/features/roomdetails/impl/src/main/res/values-lt/translations.xml index 9bdaeb86e5..dca24d285e 100644 --- a/features/roomdetails/impl/src/main/res/values-lt/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-lt/translations.xml @@ -24,4 +24,5 @@ "%1$d asmenų" "Kambario nariai" + "Šiuo metu erdvės nepalaikomi." diff --git a/features/roomdetails/impl/src/main/res/values-pl/translations.xml b/features/roomdetails/impl/src/main/res/values-pl/translations.xml index f446cb8334..4b805104f7 100644 --- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml @@ -42,6 +42,9 @@ "Masz niezapisane zmiany." "Zapisać zmiany?" "Dodaj temat" + "Naciśnij i mów" + "Pokój audio" + "Rozmowy wideo" "Szyfrowany" "Nieszyfrowany" "Pokój publiczny" @@ -67,6 +70,9 @@ "Profil" "Prośby o dołączenie" "Role i uprawnienia" + "Naciśnij i mów" + "Pokój audio" + "Rozmowy wideo" "Nazwa" "Bezpieczeństwo i prywatność" "Bezpieczeństwo" diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml index ced134da3d..a9e89c36fa 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -3,10 +3,10 @@ "Membrii noi nu pot vedea istoricul" "Membrii noi pot vedea istoricul" "Oricine poate vedea istoricul" - "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "Pentru ca această cameră să fie vizibilă în catalogul public, veți avea nevoie de o adresă de cameră." "Editați adresa" - "A apărut o eroare în timpul actualizării setărilor pentru notificari." - "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere." + "A apărut o eroare în timpul actualizării setărilor pentru notificări." + "Furnizorul contului dvs. nu acceptă această opțiune în conversațiile criptate, este posibil să nu primiți notificări în unele conversații." "Sondaje" "Administrator" "Interziceți persoane" @@ -17,17 +17,17 @@ "Mesaje și conținut" "Moderator" "Îndepărtați persoane" - "Schimbați avatarul camerei" + "Schimbați avatarul" "Editați detaliile" - "Schimbă numele camerei" - "Schimbați subiectul camerei" + "Schimbați numele" + "Schimbați subiectul" "Trimiteți mesaje" "Editați administratorii" - "Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune." - "Adăugați administrator?" - "Nu veți putea anula această acțiune. Transferați dreptul de proprietate către utilizatorii selectați. Odată ce părăsiți această pagină, acțiunea va fi definitivă." + "Promovați utilizatorul la același nivel de putere ca dvs. Nu veți putea anula această acțiune." + "Adăugați un administrator?" + "Nu veți putea anula această acțiune. Transferați proprietatea către utilizatorii selectați. Odată ce părăsiți această pagină, acțiunea va fi definitivă." "Transferați proprietatea?" - "Retrogradare" + "Retrogradați" "Nu veți putea anula această modificare, deoarece vă retrogradați. Dacă sunteți ultimul utilizator privilegiat din cameră, va fi imposibil să recâștigați privilegiile." "Vreți să vă retrogradați?" "%1$s (În așteptare)" @@ -41,21 +41,24 @@ "Membri" "Aveți modificări nesalvate." "Salvați modificările?" - "Adăugare subiect" + "Adăugați subiect" + "Push-to-talk" + "Audio în cameră" + "Apeluri video" "Criptat" "Necriptat" "Cameră publică" "Editați detaliile" - "A apărut o eroare la actualizarea detaliilor camerei" + "A apărut o eroare necunoscută și informațiile nu au putut fi modificate." "Nu s-a putut actualiza camera" - "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." + "Mesajele sunt securizate cu încuietori. Doar dvs. și destinatarii aveți cheile unice pentru a le debloca." "Criptarea mesajelor este activată" - "A apărut o eroare la încărcarea setărilor pentru notificari." - "Dezactivarea notificarilor pentru această cameră a eșuat, încercați din nou." - "Activarea notificarilor pentru această cameră a eșuat, încercați din nou." + "A apărut o eroare la încărcarea setărilor pentru notificări." + "Nu s-au putut dezactiva notificările pentru această cameră, încercați din nou." + "Nu s-au putut activa notificările pentru această cameră, încercați din nou." "Nu închideți aplicația până nu se termină." "Se pregătesc invitațiile…" - "Invitați prieteni" + "Invitați persoane" "Invitați" "Părăsiți conversația" "Părăsiți camera" @@ -67,13 +70,16 @@ "Profil" "Cereri de alăturare" "Roluri și permisiuni" + "Push-to-talk" + "Audio în cameră" + "Apeluri video" "Nume" - "Securitate & confidențialitate" + "Securitate și confidențialitate" "Securitate" "Partajați camera" - "Informatii camera" + "Informații cameră" "Subiect" - "Se actualizează camera…" + "Se actualizează detaliile…" "Nu există utilizatori interziși." "%1$d Interzis" @@ -81,17 +87,17 @@ "%1$d Interziși" "Verificați ortografia sau încercați o căutare nouă" - "Niciun rezultat pentru “%1$s”" + "Niciun rezultat pentru „%1$s”" "%1$d persoană" "%1$d persoane" - "%1$d persoane" + "%1$d de persoane" - "Îndepărtați și interziceți membrul" + "Interziceți membrul" "Doar înlăturare" "Anulare excludere" - "Se vor putea alătura din nou acestei săli dacă sunt invitați." - "Revocati excluderea din camera" + "Se va putea alătura din nou acestei camere dacă primește invitație." + "Revocați excluderea din cameră" "Excluși" "Membri" @@ -104,27 +110,27 @@ "Moderator" "Proprietar" "Membrii camerei" - "Se anulează interzicerea lui %1$s" + "Se anulează interzicerea pentru %1$s" "Permiteți setări personalizate" "Activarea acestei opțiuni va anula setările implicite." - "Anunțați-mă în acestă cameră pentru" + "Anunțați-mă în această conversație pentru" "Îl puteți schimba în %1$s." "Setări generale" "Setare implicită" "Ștergeți setarea personalizată" - "A apărut o eroare la încărcarea setărilor pentry notificari." - "Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou." - "Nu s-a reușit setarea modului, vă rugăm să încercați din nou." - "Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, nu veți primi notificări în această cameră." + "A apărut o eroare la încărcarea setărilor pentru notificări." + "Nu s-a putut restabili modul implicit, încercați din nou." + "Nu s-a putut seta modul, încercați din nou." + "Furnizorul contului dvs. nu acceptă această opțiune în conversațiile criptate, nu veți primi notificări în această conversație." "Toate mesajele" - "Numai mențiuni și cuvinte cheie" - "Mut" + "Numai mențiuni și răspunsuri" + "Dezactivare sunet" "În această cameră, anunțați-mă pentru" "Administratori" "Administratori și proprietari" - "Schimbare rol" - "Degradare la membru" - "Degradare la moderator" + "Schimbați-mi rolul" + "Retrogradați la membru" + "Retrogradați la moderator" "Moderarea membrilor" "Mesaje și conținut" "Moderatori" @@ -140,20 +146,20 @@ "Marcați ca necitită" "Adăugați o adresă" "Oricine se află în spațiile autorizate se poate alătura, dar toți ceilalți trebuie să solicite accesul." - "Toată lumea trebuie să solicite acces." + "Toată lumea trebuie să solicite accesul." "Solicitați să vă alăturați" - "Oricine în %1$s se poate alătura, dar toți ceilalți trebuie să solicite acces." + "Oricine din %1$s se poate alătura, dar toți ceilalți trebuie să solicite acces." "Da, activați criptarea" - "Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei de la momentul la care au fost invitați sau de la momentul la care s-au alăturat camerei. -Nimeni în afară de membrii camerei nu va putea citi messaje. Acest lucru poate împiedica funcționarea corectă a boților și a punților. + "Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei din momentul în care au fost invitați sau din momentul în care s-au alăturat camerei. +Nimeni în afară de membrii camerei nu va putea citi mesajele. Acest lucru poate împiedica funcționarea corectă a boților și a punților. Nu recomandăm activarea criptării pentru camerele pe care oricine le poate găsi și la care se poate alătura." "Activați criptarea?" "Odată activată, criptarea nu poate fi dezactivată." "Criptare" "Activați criptarea end-to-end" - "Oricine se poate alătura." + "Oricine se poate alătura acestei camere, inclusiv persoane din afara organizației." "Oricine" - "Alegeți membrii căror spații se pot alătura acestei cameră fără invitație. %1$s" + "Alegeți spațiile din care membrii se pot alătura acestei camere fără invitație. %1$s" "Gestionați spațiile" "Doar persoanele invitate se pot alătura." "Doar pe bază de invitație" @@ -162,20 +168,20 @@ Nu recomandăm activarea criptării pentru camerele pe care oricine le poate gă "Oricine din %1$s se poate alătura." "Membrii spațiului" "Spațiile nu sunt momentan suportate." - "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "Pentru ca această cameră să fie vizibilă în catalogul public, veți avea nevoie de o adresă de cameră." "Adresă" - "Permiteți găsirea acestei camere prin căutarea în directorul de camere publice al %1$s" - "Permiteți găsirea prin căutarea în directorul public." - "Vizibilă în directorul de camere publice" + "Permiteți găsirea acestei camere prin căutarea în catalogul camerelor publice al %1$s" + "Permiteți găsirea prin căutarea în catalogul public." + "Vizibilă în catalogul camerelor publice" "Oricine (istoricul este public)" "Modificările nu vor afecta mesajele anterioare, ci doar pe cele noi. %1$s" "Cine poate citi mesajele anterioare" "Membri de la momentul invitației" "Membri (istoric complet)" - "Adresele camerelor sunt modalități de a găsi și accesa camere. Acest lucru vă asigură, de asemenea, că puteți partaja cu ușurință camera dumneavoastră cu alte persoane. -Puteți alege să publicați camera în directorul public al camerelor serverului dumneavoastră." + "Adresele camerelor sunt modalități de a găsi și accesa camere. Adresa să permite și să puteți partaja cu ușurință camera dvs. cu alte persoane. +Puteți alege să publicați camera în catalogul camerelor publice de pe homeserverului dvs." "Publicare cameră" "Adresele sunt o modalitate de a găsi și accesa camere și spații. Acest lucru asigură, de asemenea, că le puteți partaja cu ușurință cu alții." "Vizibilitate" - "Securitate & confidențialitate" + "Securitate și confidențialitate" diff --git a/features/roomdetails/impl/src/main/res/values-uk/translations.xml b/features/roomdetails/impl/src/main/res/values-uk/translations.xml index aaf0626f46..a523f25f20 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -42,6 +42,9 @@ "У вас є не збережені зміни." "Зберегти зміни?" "Додати тему" + "Натисніть, щоб говорити" + "Аудіо в кімнаті" + "Відеовиклики" "Зашифровано" "Не зашифровано" "Загальнодоступна кімната" @@ -67,6 +70,9 @@ "Профіль" "Запити на приєднання" "Ролі та дозволи" + "Натисніть, щоб говорити" + "Аудіо в кімнаті" + "Відеовиклики" "Назва" "Безпека й приватність" "Безпека" diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml index dd204b4b55..f860f5b74c 100644 --- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -42,6 +42,9 @@ "你有未保存的更改。" "保存更改?" "添加主题" + "对讲" + "房间音频" + "视频通话" "已加密" "未加密" "公共房间" @@ -67,6 +70,9 @@ "个人资料" "申请加入" "角色与权限" + "对讲" + "房间音频" + "视频通话" "名称" "安全与隐私" "安全" diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index b08e2570c0..4ee26bb578 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -42,6 +42,9 @@ "You have unsaved changes." "Save changes?" "Add topic" + "Push to talk" + "Room Audio" + "Video calls" "Encrypted" "Not encrypted" "Public room" @@ -67,6 +70,9 @@ "Profile" "Requests to join" "Roles & permissions" + "Push to talk" + "Room Audio" + "Video calls" "Name" "Security & privacy" "Security" diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt index 9a97380d21..4baf43c477 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPointTest.kt @@ -13,12 +13,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat import io.element.android.features.call.test.FakeElementCallEntryPoint -import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint -import io.element.android.features.changeroommemberroles.test.FakeRolesAndPermissionsEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.messages.test.FakeMessagesEntryPoint import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeChangeRoomMemberRolesEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeRolesAndPermissionsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetailsedit.test.FakeRoomDetailsEditEntryPoint import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtensionTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtensionTest.kt new file mode 100644 index 0000000000..9d25a6e78e --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/NoopRoomDetailsExtensionTest.kt @@ -0,0 +1,50 @@ +/* + * 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.features.roomdetails.impl + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.v2.runAndroidComposeUiTest +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test + +class NoopRoomDetailsExtensionTest : RobolectricTest() { + @Test + fun `default extension renders no additional content`() = runAndroidComposeUiTest { + val extension = NoopRoomDetailsExtension() + setContent { Column { with(extension) { Render(Modifier) } } } + onRoot().onChildren().assertCountEquals(0) + } + + @Test + fun `default extension leaves surrounding content unchanged during updates`() = runAndroidComposeUiTest { + val extension = NoopRoomDetailsExtension() + val text = mutableStateOf("Before") + setContent { + Column { + BasicText(text.value) + with(extension) { Render(Modifier) } + } + } + onNodeWithText("Before").assertExists() + onRoot().onChildren().assertCountEquals(1) + runOnIdle { text.value = "After" } + onNodeWithText("After").assertExists() + onRoot().onChildren().assertCountEquals(1) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 8d6ef8d518..da79d436e0 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -19,6 +19,8 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.core.RoomId @@ -49,6 +51,7 @@ import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.EventsRecorder @@ -85,6 +88,7 @@ class RoomDetailsPresenterTest { analyticsService: AnalyticsService = FakeAnalyticsService(), encryptionService: FakeEncryptionService = FakeEncryptionService(), clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), @@ -115,12 +119,53 @@ class RoomDetailsPresenterTest { dispatchers = dispatchers, analyticsService = analyticsService, clipboardHelper = clipboardHelper, + toastHelper = toastHelper, appPreferencesStore = appPreferencesStore, notificationCleaner = notificationCleaner, sessionPreferencesStore = sessionPreferencesStore, ) } + @Test + fun `present - copy room id on old device shows a toast`() = runTest { + val room = aJoinedRoom( + roomPermissions = roomPermissions(), + ) + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createRoomDetailsPresenter( + room = room, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.CopyRoomId) + assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value) + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy room id on new device does not show a toast`() = runTest { + val room = aJoinedRoom( + roomPermissions = roomPermissions(), + ) + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createRoomDetailsPresenter( + room = room, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { + awaitItem().eventSink(RoomDetailsEvent.CopyRoomId) + assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value) + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - initial state is created from initial room info`() = runTest { val room = aJoinedRoom( diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index 767ddd5dd9..53cb66da15 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -11,16 +11,20 @@ package io.element.android.features.roomdetails.impl import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onLast 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.v2.runAndroidComposeUiTest import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.userprofile.shared.aUserProfileState +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.api.room.RoomNotificationMode @@ -41,6 +45,12 @@ import org.junit.Test import org.robolectric.annotation.Config class RoomDetailsViewTest : RobolectricTest() { + @Test + fun `extension section is rendered`() = runAndroidComposeUiTest { + setRoomDetailView(additionalSections = { Text("Extra section") }) + onNodeWithText("Extra section").assertExists() + } + @Test fun `click on back invokes expected callback`() = runAndroidComposeUiTest { ensureCalledOnce { callback -> @@ -381,6 +391,7 @@ private fun AndroidComposeUiTest.setRoomDetailView( onSecurityAndPrivacyClick: () -> Unit = EnsureNeverCalled(), onProfileClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: () -> Unit = EnsureNeverCalled(), + additionalSections: @Composable ColumnScope.() -> Unit = {}, ) { setContent { RoomDetailsView( @@ -402,6 +413,7 @@ private fun AndroidComposeUiTest.setRoomDetailView( onProfileClick = onProfileClick, onReportRoomClick = onReportRoomClick, leaveRoomView = {}, + additionalSections = additionalSections, ) } } diff --git a/features/roomdetailsedit/impl/build.gradle.kts b/features/roomdetailsedit/impl/build.gradle.kts index 6b1d886abe..8d5303c7a9 100644 --- a/features/roomdetailsedit/impl/build.gradle.kts +++ b/features/roomdetailsedit/impl/build.gradle.kts @@ -35,23 +35,16 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediaupload.api) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) implementation(projects.services.analytics.api) implementation(projects.libraries.testtags) api(projects.features.roomdetailsedit.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediapickers.test) - testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.permissions.test) - testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.services.analytics.test) } diff --git a/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt index 04c8796285..6c2eb0c889 100644 --- a/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.roomdetailsedit.impl import androidx.activity.compose.BackHandler @@ -19,7 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember diff --git a/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml index 401eeee575..3e4f5b3adf 100644 --- a/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ "Editați detaliile" - "A apărut o eroare la actualizarea detaliilor camerei" + "A apărut o eroare necunoscută și informațiile nu au putut fi modificate." "Nu s-a putut actualiza camera" - "Se actualizează camera…" + "Se actualizează detaliile…" diff --git a/features/roomdetailsedit/test/build.gradle.kts b/features/roomdetailsedit/test/build.gradle.kts index ff9110eb49..99082d0135 100644 --- a/features/roomdetailsedit/test/build.gradle.kts +++ b/features/roomdetailsedit/test/build.gradle.kts @@ -11,12 +11,11 @@ plugins { } android { - namespace = "io.element.android.features.roomdetetailsedit.test" + namespace = "io.element.android.features.roomdetailsedit.test" } dependencies { implementation(projects.features.roomdetailsedit.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts index 1c63872fc8..fa9492ecf7 100644 --- a/features/roomdirectory/impl/build.gradle.kts +++ b/features/roomdirectory/impl/build.gradle.kts @@ -30,12 +30,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.androidutils) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) - implementation(projects.services.analytics.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt index 3e9c6e05c4..3da8651c61 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable @@ -83,7 +82,6 @@ fun RoomDirectoryView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDirectoryTopBar( onBackClick: () -> Unit, diff --git a/features/roomdirectory/impl/src/main/res/values-ro/translations.xml b/features/roomdirectory/impl/src/main/res/values-ro/translations.xml index c996f87f8a..56237b284f 100644 --- a/features/roomdirectory/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdirectory/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,5 @@ - "Încărcare eșuată" - "Director de camere" + "Nu s-a putut încărca" + "Catalogul camerelor" diff --git a/features/roommembermoderation/api/build.gradle.kts b/features/roommembermoderation/api/build.gradle.kts index e168d2c8bf..437f10f2d8 100644 --- a/features/roommembermoderation/api/build.gradle.kts +++ b/features/roommembermoderation/api/build.gradle.kts @@ -16,7 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.matrix.api) } diff --git a/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml b/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml index 9f21c8d351..a778b7e0ab 100644 --- a/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml @@ -1,22 +1,22 @@ - "Îndepărtați și interziceți membrul" - "Interzicere" - "Nu se vor putea alătura din nou acestei camere dacă sunt invitați." - "Sunteți sigur că doriți să interziceți acest membru?" - "Nu vor putea să se alăture din nou acestui spațiu dacă sunt invitați, dar își vor păstra în continuare calitatea de membru al oricăror camere sau subspații." - "Se interzice %1$s" + "Interziceți membrul" + "Interziceți" + "Nu se va putea alătura din nou acestei camere dacă primește invitație." + "Sigur doriți să interziceți acest membru?" + "Nu vor putea să se alăture din nou acestui spațiu dacă sunt invitați, dar își vor păstra calitatea de membru al oricăror camere sau subspații." + "Se interzice utilizatorul %1$s" "Îndepărtați" - "Se vor putea alătura din nou acestei săli dacă sunt invitați." - "Sunteți sigur că doriți să îndepărtați acest membru?" - "Vor putea să se alăture din nou acestui spațiu dacă sunt invitați și își vor păstra în continuare calitatea de membru al oricăror camere sau subspații." + "Se va putea alătura din nou acestei camere dacă primește invitație." + "Sigur doriți să îndepărtați acest membru?" + "Vor putea să se alăture din nou acestui spațiu dacă sunt invitați și își vor păstra calitatea de membru al oricăror camere sau subspații." "Vizualizare profil" "Înlăturați membrul" "Înlăturați membrul și interziceți-i să se alăture în viitor?" "Se îndepărtează %1$s" - "Revocati excluderea din camera" + "Revocați excluderea din cameră" "Anulare excludere" "Aceștia se vor putea alătura din nou camerei dacă sunt invitați." - "Sunteți sigur că doriți să dezactivați excluderea impusă acestui membru?" - "Se anulează interzicerea lui %1$s" + "Sigur doriți să revocați excluderea impusă acestui membru?" + "Se anulează interzicerea pentru %1$s" diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index 54d87ef22e..284517ce4a 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -33,9 +33,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.oauth.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) api(libs.statemachine) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt index 27612a7b4d..79cadbd132 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/reset/password/ResetIdentityPasswordView.kt @@ -9,14 +9,21 @@ package io.element.android.features.securebackup.impl.reset.password import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.PreviewParameter @@ -42,6 +49,11 @@ fun ResetIdentityPasswordView( modifier: Modifier = Modifier, ) { val passwordState = textFieldState(stateValue = "") + val submit = { + if (passwordState.value.isNotEmpty()) { + state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) + } + } FlowStepPage( modifier = modifier, iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()), @@ -58,13 +70,14 @@ fun ResetIdentityPasswordView( passwordState.value = newText }, hasError = state.resetAction.isFailure(), + onSubmit = submit, ) }, buttons = { Button( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_reset_identity), - onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) }, + onClick = submit, destructive = true, enabled = passwordState.value.isNotEmpty(), ) @@ -78,12 +91,20 @@ fun ResetIdentityPasswordView( } @Composable -private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) { +private fun Content( + text: String, + onTextChange: (String) -> Unit, + hasError: Boolean, + onSubmit: () -> Unit, +) { var showPassword by remember { mutableStateOf(false) } TextField( modifier = Modifier .fillMaxWidth() - .onTabOrEnterKeyFocusNext(LocalFocusManager.current), + .onTabOrEnterKeyFocusNext(LocalFocusManager.current) + .semantics { + contentType = ContentType.Password + }, value = text, onValueChange = onTextChange, placeholder = stringResource(CommonStrings.common_password), @@ -95,6 +116,13 @@ private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Bool onToggle = { showPassword = !showPassword }, ) }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmit() } + ), validity = if (hasError) TextFieldValidity.Invalid else TextFieldValidity.None, supportingText = if (hasError) { stringResource(R.string.screen_reset_encryption_password_error) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt index 68221a29e4..a71c235857 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt @@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup sealed interface SecureBackupSetupEvent { data object CreateRecoveryKey : SecureBackupSetupEvent data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent + data object CopyRecoveryKey : SecureBackupSetupEvent data object Done : SecureBackupSetupEvent data object DismissDialog : SecureBackupSetupEvent } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt index 538128a0d5..88338c6af4 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -22,9 +22,12 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.loggerTagSetup import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -38,6 +41,8 @@ class SecureBackupSetupPresenter( @Assisted private val isChangeRecoveryKeyUserStory: Boolean, private val stateMachine: SecureBackupSetupStateMachine, private val encryptionService: EncryptionService, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory interface Factory { @@ -60,6 +65,13 @@ class SecureBackupSetupPresenter( } SecureBackupSetupEvent.RecoveryKeyHasBeenSaved -> stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) + SecureBackupSetupEvent.CopyRecoveryKey -> { + val recoveryKey = setupState.recoveryKey() ?: return + clipboardHelper.copyPlainText(recoveryKey, isSensitive = true) { + toastHelper.show(R.string.screen_recovery_key_copied_to_clipboard) + } + stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) + } SecureBackupSetupEvent.DismissDialog -> { showSaveConfirmationDialog = false stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt index c22bd44c99..d7c63e3a39 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.components.BigIcon @@ -114,16 +113,10 @@ private fun subtitle(state: SecureBackupSetupState): String { private fun Content( state: SecureBackupSetupState, ) { - val context = LocalContext.current val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey - val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard) val clickLambda = if (formattedRecoveryKey != null) { { - context.copyToClipboard( - text = formattedRecoveryKey, - toastMessage = toastMessage, - ) - state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved) + state.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) } } else { if (!state.recoveryKeyViewState.inProgress) { diff --git a/features/securebackup/impl/src/main/res/values-cs/translations.xml b/features/securebackup/impl/src/main/res/values-cs/translations.xml index 171900cede..2f43668287 100644 --- a/features/securebackup/impl/src/main/res/values-cs/translations.xml +++ b/features/securebackup/impl/src/main/res/values-cs/translations.xml @@ -20,6 +20,12 @@ "Postupujte podle pokynů k vytvoření nového obnovovacího klíče" "Uložte nový klíč pro obnovení do správce hesel nebo do zašifrované poznámky" "Obnovte šifrování účtu pomocí jiného zařízení" + "Zadejte znovu svůj klíč pro obnovení." + "Potvrďte svůj klíč pro obnovení" + "Vyberte si klíč pro obnovení, který si budete moci zapamatovat." + "Zadejte klíč pro obnovení" + "Pomocí svého klíče pro obnovení můžete potvrdit nová zařízení nebo obnovit šifrované chaty, pokud ztratíte přístup ke všem svým zařízením." + "Vaše záloha je nyní plně nastavena" "Pokračovat v resetování" "Podrobnosti o vašem účtu, kontaktech, preferencích a seznamu chatu budou zachovány" "Ztratíte svou stávající historii zpráv" diff --git a/features/securebackup/impl/src/main/res/values-el/translations.xml b/features/securebackup/impl/src/main/res/values-el/translations.xml index 0f4584271c..96fecb2895 100644 --- a/features/securebackup/impl/src/main/res/values-el/translations.xml +++ b/features/securebackup/impl/src/main/res/values-el/translations.xml @@ -20,6 +20,26 @@ "Ακολούθησε τις οδηγίες για να δημιουργήσεις ένα νέο κλειδί ανάκτησης" "Αποθήκευσε το νέο κλειδί ανάκτησης σε έναν διαχειριστή κωδικών πρόσβασης ή σε κρυπτογραφημένη σημείωση" "Επανάφερε την κρυπτογράφηση για το λογαριασμό σου χρησιμοποιώντας άλλη συσκευή" + "Το κλειδί ανάκτησης που εισαγάγατε δεν ταιριάζει" + "Ολοκλήρωση της ρύθμισης" + "Εισαγάγετε ξανά το κλειδί ανάκτησης." + "Επιβεβαιώστε το κλειδί ανάκτησης" + + "Ελάχιστος αριθμός χαρακτήρων: %1$d. Μην χρησιμοποιείτε τον κωδικό πρόσβασης του λογαριασμού σας" + "Ελάχιστος αριθμός χαρακτήρων: %1$d. Μην χρησιμοποιείτε τον κωδικό πρόσβασης του λογαριασμού σας" + + "Δύναμη" + "Ισχύς φράσης πρόσβασης: %1$s" + "Μέτριο" + "Ισχυρό" + "Πολύ ισχυρό" + "Πολύ αδύναμο" + "Αδύναμο" + "Επιλέξτε ένα κλειδί ανάκτησης που μπορείτε να απομνημονεύσετε." + "Εισαγάγετε ένα κλειδί ανάκτησης" + "Φόρτωση απαιτήσεων κλειδιού ανάκτησης" + "Για να αλλάξετε το κλειδί ανάκτησης, μεταβείτε στις Ρυθμίσεις → Κρυπτογράφηση → Αντίγραφα ασφαλείας" + "Το αντίγραφο ασφαλείας σας έχει πλέον ρυθμιστεί πλήρως" "Συνέχιση επαναφοράς" "Τα στοιχεία του λογαριασμού σου, οι επαφές, οι προτιμήσεις και η λίστα συνομιλιών θα διατηρηθούν" "Θα χάσεις το υπάρχον ιστορικό μηνυμάτων σου" diff --git a/features/securebackup/impl/src/main/res/values-hr/translations.xml b/features/securebackup/impl/src/main/res/values-hr/translations.xml index 618be910fe..3c44159d12 100644 --- a/features/securebackup/impl/src/main/res/values-hr/translations.xml +++ b/features/securebackup/impl/src/main/res/values-hr/translations.xml @@ -41,10 +41,10 @@ "Za promjenu ključa za oporavak, idite na Postavke → Šifriranje → Sigurnosna kopija" "Ključ za oporavak možete koristiti za potvrdu novih uređaja ili vraćanje šifriranih razgovora ako izgubite pristup svim svojim uređajima." "Vaša sigurnosna kopija je sada potpuno postavljena" - "Nastavi s poništavanjem" + "Nastavi" "Sačuvat će se podatci o vašem računu, kontakti, postavke i popis razgovora" "Izgubit ćete svu povijest poruka koja je pohranjena samo na poslužitelju" - "Morat ćete ponovno potvrditi sve svoje postojeće uređaje i kontakte" + "Drugi će korisnici vidjeti da je vaš digitalni identitet poništen" "Poništite svoj identitet samo ako nemate pristup drugom prijavljenom uređaju i ako ste izgubili ključ za oporavak." "Ne možete potvrditi? Morat ćete resetirati svoj digitalni identitet." "Izbriši" diff --git a/features/securebackup/impl/src/main/res/values-hu/translations.xml b/features/securebackup/impl/src/main/res/values-hu/translations.xml index 3d89c15c5c..946bea7be6 100644 --- a/features/securebackup/impl/src/main/res/values-hu/translations.xml +++ b/features/securebackup/impl/src/main/res/values-hu/translations.xml @@ -41,10 +41,10 @@ "A helyreállítási kulcs módosításához lépjen a Beállítások → Titkosítás → Biztonsági mentés menüpontra." "A helyreállítási kulcs segítségével megerősítheti az új eszközöket, vagy visszaállíthatja a titkosított csevegéseket, ha elveszítette a hozzáférését az összes eszközéhez." "A biztonsági mentés most már teljesen be van állítva" - "Visszaállítás folytatása" + "Folytatás" "A fiókadatok, a kapcsolatok, a beállítások és a csevegéslista megmarad" "Elveszíti meglévő üzenetelőzményeit" - "Újból ellenőriznie kell az összes meglévő eszközét és csevegőpartnerét" + "A többi felhasználó számára látható lesz, hogy digitális identitása visszaállításra került" "Csak akkor állítsa alaphelyzetbe a személyazonosságát, ha nem fér hozzá más bejelentkezett eszközhöz, és elveszítette a helyreállítási kulcsát." "Nem tudja megerősíteni? Alaphelyzetbe kell állítania a digitális személyazonosságát." "Törlé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 d115314038..7727a75394 100644 --- a/features/securebackup/impl/src/main/res/values-pl/translations.xml +++ b/features/securebackup/impl/src/main/res/values-pl/translations.xml @@ -42,10 +42,10 @@ "Aby zmienić klucz przywracania przejdź do Ustawienia → Szyfrowanie → Kopia zapasowa" "Możesz użyć klucza przywracania, żeby potwierdzić nowe urządzenia lub przywrócić zaszyfrowane czaty, jeśli stracisz dostęp do wszystkich swoich urządzeń." "Twoja kopia zapasowa jest gotowa." - "Kontynuuj resetowanie" + "Kontynuuj" "Szczegóły konta, kontakty, preferencje i lista czatów zostaną zachowane" "Utracisz istniejącą historię wiadomości" - "Wymagana będzie ponowna weryfikacja istniejących urządzeń i kontaktów" + "Pozostali użytkownicy zobaczą, że Twoja tożsamość cyfrowa została zresetowana" "Zresetuj swoją tożsamość tylko wtedy, gdy nie posiadasz dostępu do żadnego innego zweryfikowanego urządzenia i straciłeś swój klucz przywracania." "Nie możesz potwierdzić? Zresetuj swoją tożsamość cyfrową." "Usuń" 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 c9060945c7..1964e43f2c 100644 --- a/features/securebackup/impl/src/main/res/values-ro/translations.xml +++ b/features/securebackup/impl/src/main/res/values-ro/translations.xml @@ -1,25 +1,25 @@ - "Dezactivați backupul" + "Dezactivați backupul cheilor" "Activați backupul" - "Acest lucru vă va permite să vizualizați istoricul camerelor pe orice dispozitiv nou și este necesar pentru backupul mesajelor și al identității digitale. %1$s." - "Backup" - "Stocarea cheilor trebuie activată pentru a face un backup al mesajelor." + "Acest lucru vă va permite să vizualizați istoricul conversațiilor pe orice dispozitiv nou și este necesar pentru backupul conversațiilor și al identității digitale. %1$s." + "Backup chei" + "Backupul cheilor trebuie activat pentru a face un backup al conversațiilor." "Încărcați cheile de pe acest dispozitiv" - "Permiteți stocarea cheilor" + "Permiteți backupul cheilor" "Schimbați 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." + "Conversațiile dvs. sunt salvate automat cu criptare end-to-end. Pentru a restabili acest backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dvs., veți avea nevoie de cheia de recuperare." "Introduceți cheia de recuperare" - "Backup-ul pentru chat nu este sincronizat în prezent." + "Backupul cheilor 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ă 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." + "Conversațiile dvs. sunt salvate automat cu criptare end-to-end. Pentru a restabili acest backup și a vă păstra identitatea digitală atunci când pierdeți accesul la toate dispozitivele dvs., 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" + "Conectați-vă din nou la contul dvs." + "Când vi se cere să vă verificați dispozitivul, selectați %1$s" "„Resetați tot”" "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" + "Resetați criptarea contului dvs. folosind un alt dispozitiv" "Cheia de recuperare introdusă nu se potrivește" "Finalizați configurarea" "Introduceți din nou cheia de recuperare." @@ -27,9 +27,9 @@ "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." + "Minim %1$d de caractere. Nu folosiți parola contului dvs." - "Rezistenţă" + "Rezistență" "Rezistența parolei: %1$s" "Moderată" "Puternică" @@ -40,31 +40,31 @@ "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" + "Puteți folosi cheia de recuperare pentru a confirma dispozitive noi sau pentru a restabili conversațiile criptate dacă pierdeți accesul la toate dispozitivele dvs." + "Backupul dvs. 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" + "Detaliile contului, contactele, preferințele și lista de conversații vor fi păstrate" + "Veți pierde orice istoric de mesaje care este stocat doar pe server" "Va trebui să verificați din nou toate dispozitivele și contactele existente" "Resetați-vă identitatea digitală numai dacă nu aveți acces la un alt dispozitiv conectat și ați pierdut cheia de recuperare." "Nu puteți confirma? Va trebui să vă resetați identitatea digitală." - "Dezactivare" - "Veți pierde mesajele criptate dacă sunteți deconectat de pe toate dispozitivele." - "Sunteți sigur că doriți să dezactivați backup-ul?" - "Dezactivarea backup-ului va șterge backup-ul curent și va dezactiva alte măsuri de securitate. În acest caz, veți:" + "Ștergeți" + "Veți pierde conversațiile criptate și va trebui să vă resetați identitatea digitală dacă vă deconectați de pe toate dispozitivele." + "Sigur doriți să dezactivați backupul cheilor?" + "Dezactivarea backupului cheilor va șterge identitate digitală și cheile mesajelor actuale și va dezactiva alte măsuri de securitate. În acest caz:" "Nu veți avea mesajele anterioare criptate pe dispozitive noi" "Veți pierde accesul la mesajele criptate dacă sunteți deconectat de pe %1$s peste tot" - "Sunteți sigur că doriți să dezactivați backup-ul?" + "Sigur doriți să dezactivați backupul cheilor și să-l ștergeți?" "Obțineți o nouă cheie de recuperare dacă ați pierdut-o pe cea existentă. După schimbarea cheii de recuperare, cea veche nu va mai funcționa." "Generați o nouă cheie de recuperare" - "Nu împărtășiți cheia cu nimeni!" + "Nu partajați cheia cu nimeni!" "Cheia de recuperare a fost schimbată" "Schimbați cheia de recuperare?" "Creați o nouă cheie de recuperare" "Asigurați-vă că nimeni nu poate vedea acest ecran!" - "Vă rugăm să încercați din nou să confirmați accesul la backup." + "Încercați din nou să confirmați accesul la backup cheilor." "Cheie de recuperare incorectă" - "Dacă aveți o cheie de securitate sau o frază de securitate, aceasta va funcționa și ea." + "Dacă aveți o cheie de securitate sau o frază de securitate, și aceasta va funcționa." "Introduceți…" "Ați pierdut cheia de recuperare?" "Cheia de recuperare confirmată" @@ -72,20 +72,20 @@ "Cheia de recuperare copiată" "Se generează…" "Salvați cheia de recuperare" - "Notați cheia de recuperare undeva în siguranță sau salvați-o într-un manager de parole." - "Apăsați pentru a copia cheia de recuperare" - "Salvați cheia de recuperare" + "Notați această cheie de recuperare într-un loc sigur, cum ar fi un manager de parole, o notă criptată sau un seif." + "Atingeți pentru a copia cheia de recuperare" + "Salvați cheia de recuperare într-un loc sigur" "Nu veți putea accesa noua cheie de recuperare după acest pas." "Ați salvat cheia de recuperare?" - "Backup-ul pentru chat este protejat de o cheie de recuperare. Dacă aveți nevoie de o nouă cheie de recuperare după configurare, puteți să o recreați selectând „Schimbați cheia de recuperare”." + "Backupul cheilor este protejat de o cheie de recuperare. Dacă aveți nevoie de o nouă cheie de recuperare după configurare, puteți să creați alta selectând „Schimbați cheia de recuperare”." "Generați cheia de recuperare" - "Nu împărtășiți cheia cu nimeni!" + "Nu partajați cheia cu nimeni!" "Configurarea recuperării a reușit" "Obțineți cheia de recuperare" "Da, resetați acum" "Acest proces este ireversibil." - "Sunteți sigur că doriți să vă resetați identitatea digitală?" - "S-a produs o eroare necunoscută. Vă rugăm să verificați dacă parola contului dvs. este corectă și să încercați din nou." + "Sigur doriți să vă resetați identitatea digitală?" + "S-a produs o eroare necunoscută. Verificați dacă parola contului dvs. este corectă și să încercați din nou." "Introduceți…" "Confirmați că doriți să vă resetați identitatea digitală." "Introduceți parola contului pentru a continua" diff --git a/features/securebackup/impl/src/main/res/values-uk/translations.xml b/features/securebackup/impl/src/main/res/values-uk/translations.xml index c75f300b05..7d98c399d7 100644 --- a/features/securebackup/impl/src/main/res/values-uk/translations.xml +++ b/features/securebackup/impl/src/main/res/values-uk/translations.xml @@ -42,10 +42,10 @@ "Щоб змінити ключ відновлення, перейдіть до Налаштування → Шифрування → Резервне копіювання" "Ви можете використовувати свій ключ відновлення для підтвердження нових пристроїв або відновлення зашифрованих бесід, якщо втратите доступ до всіх своїх пристроїв." "Налаштування резервного копіювання завершено" - "Продовжити скидання налаштувань" + "Продовжити" "Дані вашого облікового запису, контакти, налаштування й бесіди будуть збережені" "Ви втратите свою наявну історію повідомлень" - "Вам потрібно буде повторно звірити всі наявні пристрої та контакти" + "Інші користувачі побачать, що вашу цифрову ідентичність було скинуто" "Скидайте свою цифрову ідентичність лише якщо у вас немає доступу до іншого звіреного пристрою та ключа відновлення." "Не можете підтвердити? Вам доведеться скинути свою цифрову ідентичність." "Видалити" diff --git a/features/securebackup/impl/src/main/res/values-zh/translations.xml b/features/securebackup/impl/src/main/res/values-zh/translations.xml index 2b59a54ada..784eee941d 100644 --- a/features/securebackup/impl/src/main/res/values-zh/translations.xml +++ b/features/securebackup/impl/src/main/res/values-zh/translations.xml @@ -40,10 +40,10 @@ "转到“设置”→“加密”→“备份”以更改恢复密钥。" "如果你无法访问所有设备,可以使用恢复密钥以确认新设备或恢复加密聊天。" "你已完成备份设置" - "继续重置" + "继续" "你的账户信息、联系人、偏好和聊天列表将被保留" "你将丢失所有仅存储在服务器上的消息历史" - "你将需要再次验证所有现有设备与联系人" + "其他用户将会看到你已重置数字身份。" "仅当你无法访问其它已登录的设备并且丢失了恢复密钥时才重置数字身份。" "无法确认?你需要重置数字身份。" "删除" diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml index 851a2d8185..fe2fda70c8 100644 --- a/features/securebackup/impl/src/main/res/values/localazy.xml +++ b/features/securebackup/impl/src/main/res/values/localazy.xml @@ -41,10 +41,10 @@ "To change your recovery key, go to Settings → Encryption → Backup" "You can use your recovery key to confirm new devices or restore your encrypted chats if you lose access to all your devices." "Your backup is now fully set up" - "Continue reset" + "Continue" "Your account details, contacts, preferences, and chat list will be kept" "You will lose any message history that’s stored only on the server" - "You will need to verify all your existing devices and contacts again" + "Other users will see that your digital identity has been reset" "Only reset your digital identity if you don\'t have access to another verified device and you don\'t have your recovery key." "Can\'t confirm? You’ll need to reset your digital identity." "Delete" diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt index e37dab748f..3b029b2ba2 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt @@ -15,6 +15,8 @@ import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.node.TestParentNode @@ -40,6 +42,8 @@ class DefaultSecureBackupSetupEntryPointTest { isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = FakeEncryptionService(), + clipboardHelper = FakeClipboardHelper(), + toastHelper = FakeToastHelper(), ) }, snackbarDispatcher = SnackbarDispatcher(), diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt index 6ab0b257a2..2b79863953 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -12,8 +12,13 @@ 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.securebackup.impl.R import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.test.A_RECOVERY_KEY @@ -186,16 +191,70 @@ class SecureBackupSetupPresenterTest { } } + @Test + fun `present - copy recovery key on old device shows a toast`() = runTest { + val encryptionService = FakeEncryptionService() + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey) + skipItems(1) + encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) + val createdState = awaitItem() + createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) + assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY) + assertThat(clipboardHelper.isSensitive).isTrue() + assertThat(toastHelper.shownToasts).containsExactly(R.string.screen_recovery_key_copied_to_clipboard) + assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + } + } + + @Test + fun `present - copy recovery key on new device does not show a toast`() = runTest { + val encryptionService = FakeEncryptionService() + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createSecureBackupSetupPresenter( + encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey) + skipItems(1) + encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY)) + val createdState = awaitItem() + createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) + assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY) + assertThat(clipboardHelper.isSensitive).isTrue() + assertThat(toastHelper.shownToasts).isEmpty() + assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java) + } + } + private fun createSecureBackupSetupPresenter( isChangeRecoveryKeyUserStory: Boolean = false, encryptionService: EncryptionService = FakeEncryptionService( enableRecoveryLambda = { _, _ -> Result.success("") }, ), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): SecureBackupSetupPresenter { return SecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/securityandprivacy/api/build.gradle.kts b/features/securityandprivacy/api/build.gradle.kts index 4260bea661..ec29da2f8b 100644 --- a/features/securityandprivacy/api/build.gradle.kts +++ b/features/securityandprivacy/api/build.gradle.kts @@ -16,6 +16,5 @@ android { } dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/features/securityandprivacy/impl/build.gradle.kts b/features/securityandprivacy/impl/build.gradle.kts index d42bdef883..0a97231db6 100644 --- a/features/securityandprivacy/impl/build.gradle.kts +++ b/features/securityandprivacy/impl/build.gradle.kts @@ -29,7 +29,6 @@ setupDependencyInjection() dependencies { api(projects.features.securityandprivacy.api) implementation(projects.appconfig) - implementation(projects.appnav) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) @@ -39,12 +38,10 @@ dependencies { implementation(projects.libraries.previewutils) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) - implementation(projects.services.analytics.api) implementation(projects.libraries.featureflag.api) implementation(projects.features.enterprise.api) testCommonDependencies(libs, true) - testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.testtags) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt index 9eb15b7af9..5b97323887 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/editroomaddress/EditRoomAddressView.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -87,7 +86,6 @@ fun EditRoomAddressView( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EditRoomAddressTopBar( isSaveActionEnabled: Boolean, 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 0bb6bc49e6..e801eb9844 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -166,7 +165,6 @@ private fun CheckableSpaceListItem( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ManageAuthorizedSpacesTopBar( isDoneButtonEnabled: Boolean, diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index 32517cd18d..498e71dd0a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -89,6 +89,10 @@ class SecurityAndPrivacyPresenter( value = sessionEnterpriseService.isEncryptionDisabledByHomeserver() } + val arePublicRoomsDisabledByHomeserver by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + } + val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } LaunchedEffect(Unit) { isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory) @@ -254,6 +258,7 @@ class SecurityAndPrivacyPresenter( selectableJoinedSpaces = selectableJoinedSpaces, spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = ::handleEvent, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt index ddba5d0caa..5b61953674 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -28,6 +28,7 @@ data class SecurityAndPrivacyState( val editedSettings: SecurityAndPrivacySettings, val homeserverName: String, val isEncryptionDisabledByHomeserver: Boolean, + private val arePublicRoomsDisabledByHomeserver: Boolean, val showEnableEncryptionConfirmation: Boolean, private val isKnockEnabled: Boolean, val saveAction: AsyncAction, @@ -39,6 +40,11 @@ data class SecurityAndPrivacyState( ) { val isSpaceMemberSelectable = spaceSelectionMode != SpaceSelectionMode.None + // Show Anyone option when: + // - Anyone is the current saved value, OR + // - The homeserver allows public rooms + val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver + // Show SpaceMember option in two cases: // - SpaceMember is the current saved value // - SpaceMember option is selectable (ie. the FF is enabled and there is at least one space to select) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt index 102adb4015..45383bf814 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt @@ -37,6 +37,10 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider = emptySet(), spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, isEncryptionDisabledByHomeserver: Boolean = false, + arePublicRoomsDisabledByHomeserver: Boolean = false, eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -152,5 +157,6 @@ fun aSecurityAndPrivacyState( selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = eventSink, ) 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 e2cc5522a6..5b224f682c 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 @@ -22,7 +22,6 @@ import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ListItemDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -156,7 +155,6 @@ fun SecurityAndPrivacyView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SecurityAndPrivacyToolbar( isSaveActionEnabled: Boolean, @@ -234,13 +232,15 @@ private fun RoomAccessSection( title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), modifier = modifier, ) { - ListItem( - 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())), - onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, - ) + if (state.showAnyoneOption) { + ListItem( + 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())), + onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, + ) + } if (state.showSpaceMemberOption) { ListItem( content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, diff --git a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml index d0d747f086..e5a42c26a5 100644 --- a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml @@ -20,7 +20,7 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Jakmile je povoleno, šifrování nelze zakázat." "Šifrování" "Povolit koncové šifrování" - "Vstoupit může kdokoli." + "Připojit se může kdokoli, včetně osob mimo vaši organizaci." "Kdokoli" "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s" "Spravovat prostory" diff --git a/features/securityandprivacy/impl/src/main/res/values-el/translations.xml b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml index dfbf4e075c..ab031c9f1d 100644 --- a/features/securityandprivacy/impl/src/main/res/values-el/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml @@ -20,7 +20,7 @@ "Μόλις ενεργοποιηθεί, η κρυπτογράφηση δεν μπορεί να απενεργοποιηθεί." "Κρυπτογράφηση" "Ενεργοποίηση κρυπτογράφησης από άκρο σε άκρο" - "Οποιοσδήποτε μπορεί να συμμετάσχει." + "Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας." "Οποιοσδήποτε" "Επιλέξτε ποια μέλη των χώρων μπορούν να συμμετάσχουν σε αυτήν την αίθουσα χωρίς πρόσκληση. %1$s" "Διαχείριση χώρων" diff --git a/features/securityandprivacy/impl/src/main/res/values-lt/translations.xml b/features/securityandprivacy/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 0000000000..16f655eb13 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,4 @@ + + + "Šiuo metu erdvės nepalaikomi." + diff --git a/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml index 2f3eaab319..b454043e0f 100644 --- a/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml @@ -1,28 +1,28 @@ - "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "Pentru ca această cameră să fie vizibilă în catalogul public, veți avea nevoie de o adresă de cameră." "Editați adresa" - "Spațile din care membrii se pot alătura camerei fără invitație." + "Spațiile din care membrii se pot alătura camerei fără invitație." "Gestionați spațiile" "(Spațiu necunoscut)" "Alte spații din care nu faceți parte" - "Spațiile dumneavoastră" + "Spațiile dvs." "Adăugați o adresă" "Oricine se află în spațiile autorizate se poate alătura, dar toți ceilalți trebuie să solicite accesul." - "Toată lumea trebuie să solicite acces." + "Toată lumea trebuie să solicite accesul." "Solicitați să vă alăturați" - "Oricine în %1$s se poate alătura, dar toți ceilalți trebuie să solicite acces." + "Oricine din %1$s se poate alătura, dar toți ceilalți trebuie să solicite acces." "Da, activați criptarea" - "Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei de la momentul la care au fost invitați sau de la momentul la care s-au alăturat camerei. -Nimeni în afară de membrii camerei nu va putea citi messaje. Acest lucru poate împiedica funcționarea corectă a boților și a punților. + "Odată activată, criptarea pentru o cameră nu poate fi dezactivată. Mesajele anterioare vor fi vizibile numai pentru membrii camerei din momentul în care au fost invitați sau din momentul în care s-au alăturat camerei. +Nimeni în afară de membrii camerei nu va putea citi mesajele. Acest lucru poate împiedica funcționarea corectă a boților și a punților. Nu recomandăm activarea criptării pentru camerele pe care oricine le poate găsi și la care se poate alătura." "Activați criptarea?" "Odată activată, criptarea nu poate fi dezactivată." "Criptare" "Activați criptarea end-to-end" - "Oricine se poate alătura." + "Oricine se poate alătura acestei camere, inclusiv persoane din afara organizației." "Oricine" - "Alegeți membrii căror spații se pot alătura acestei cameră fără invitație. %1$s" + "Alegeți spațiile din care membrii se pot alătura acestei camere fără invitație. %1$s" "Gestionați spațiile" "Doar persoanele invitate se pot alătura." "Doar pe bază de invitație" @@ -31,20 +31,20 @@ Nu recomandăm activarea criptării pentru camerele pe care oricine le poate gă "Oricine din %1$s se poate alătura." "Membrii spațiului" "Spațiile nu sunt momentan suportate." - "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "Pentru ca această cameră să fie vizibilă în catalogul public, veți avea nevoie de o adresă de cameră." "Adresă" - "Permiteți găsirea acestei camere prin căutarea în directorul de camere publice al %1$s" - "Permiteți găsirea prin căutarea în directorul public." - "Vizibilă în directorul de camere publice" + "Permiteți găsirea acestei camere prin căutarea în catalogul camerelor publice al %1$s" + "Permiteți găsirea prin căutarea în catalogul public." + "Vizibilă în catalogul camerelor publice" "Oricine (istoricul este public)" "Modificările nu vor afecta mesajele anterioare, ci doar pe cele noi. %1$s" "Cine poate citi mesajele anterioare" "Membri de la momentul invitației" "Membri (istoric complet)" - "Adresele camerelor sunt modalități de a găsi și accesa camere. Acest lucru vă asigură, de asemenea, că puteți partaja cu ușurință camera dumneavoastră cu alte persoane. -Puteți alege să publicați camera în directorul public al camerelor serverului dumneavoastră." + "Adresele camerelor sunt modalități de a găsi și accesa camere. Adresa să permite și să puteți partaja cu ușurință camera dvs. cu alte persoane. +Puteți alege să publicați camera în catalogul camerelor publice de pe homeserverului dvs." "Publicare cameră" "Adresele sunt o modalitate de a găsi și accesa camere și spații. Acest lucru asigură, de asemenea, că le puteți partaja cu ușurință cu alții." "Vizibilitate" - "Securitate & confidențialitate" + "Securitate și confidențialitate" diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index 8cdba871be..5a0cf743e2 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest { @Test fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val presenter = createSecurityAndPrivacyPresenter( sessionEnterpriseService = sessionEnterpriseService, ) @@ -1060,6 +1065,42 @@ class SecurityAndPrivacyPresenterTest { } } + @Test + fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + consumeItemsUntilPredicate { !it.showAnyoneOption } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Public) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public) + ), + ), + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val states = consumeItemsUntilTimeout() + assertThat(states).isNotEmpty() + assertThat(states.all { it.showAnyoneOption }).isTrue() + } + } + private fun roomPermissions( canChangeRoomAccess: Boolean = true, canChangeHistoryVisibility: Boolean = true, @@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest { spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, ) ): SecurityAndPrivacyPresenter { return SecurityAndPrivacyPresenter( diff --git a/features/securityandprivacy/test/build.gradle.kts b/features/securityandprivacy/test/build.gradle.kts index 903ef6d194..1d6f0ee3a3 100644 --- a/features/securityandprivacy/test/build.gradle.kts +++ b/features/securityandprivacy/test/build.gradle.kts @@ -17,6 +17,5 @@ android { dependencies { implementation(projects.features.securityandprivacy.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt index c83d4fbdc6..e34deb9754 100644 --- a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -13,9 +13,17 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId interface ShareEntryPoint : FeatureEntryPoint { - data class Params(val shareIntentData: ShareIntentData) + /** + * @param sessionId the session to use initially. The user can select another session. + * @param shareIntentData the data to share. + */ + data class Params( + val sessionId: SessionId, + val shareIntentData: ShareIntentData, + ) fun createNode( parentNode: Node, @@ -25,6 +33,23 @@ interface ShareEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun onDone(roomIds: List) + /** + * Called when the data has been shared. + * @param sessionId the session which has been used to share the data. It can be different from the current session. + * @param roomIds the rooms the data has been shared to. + */ + fun onDone(sessionId: SessionId, roomIds: List) + + /** + * Called when the user cancels the share. + */ + fun onCancel() + + /** + * Called when the user has selected an account whose session is not verified. + * The session has to be verified before the user can share content with it. + * @param sessionId the session to verify. + */ + fun onSessionVerificationRequired(sessionId: SessionId) } } diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts index 255263cf02..f10783d60c 100644 --- a/features/share/impl/build.gradle.kts +++ b/features/share/impl/build.gradle.kts @@ -27,27 +27,20 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.roomselect.api) - implementation(projects.libraries.uiStrings) - implementation(projects.libraries.testtags) implementation(projects.services.appnavstate.api) api(libs.statemachine) api(projects.features.share.api) testCommonDependencies(libs, true) - testImplementation(projects.features.share.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.roomselect.test) testImplementation(projects.services.appnavstate.impl) } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt new file mode 100644 index 0000000000..54085791f6 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareDataSender.kt @@ -0,0 +1,102 @@ +/* + * 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.share.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import kotlin.coroutines.cancellation.CancellationException + +@ContributesBinding(AppScope::class) +class DefaultShareDataSender( + private val mediaSenderRoomFactory: MediaSenderRoomFactory, + private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory, + private val activeRoomsHolder: ActiveRoomsHolder, + private val onSharedData: OnSharedData, +) : ShareDataSender { + override suspend fun send( + client: MatrixClient, + shareIntentData: ShareIntentData, + roomIds: List, + ): Result = runCatchingExceptions { + val result = when (shareIntentData) { + is ShareIntentData.PlainText -> { + roomIds + .map { roomId -> + client.findJoinedRoom(roomId)?.liveTimeline?.sendMessage( + body = shareIntentData.content, + htmlBody = null, + intentionalMentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + is ShareIntentData.Uris -> { + val filesToShare = shareIntentData.uris + if (filesToShare.isEmpty()) { + false + } else { + // The media optimization config is the same for all the files and all the rooms + val mediaOptimizationConfig = mediaOptimizationConfigProviderFactory.create(client).get() + roomIds + .map { roomId -> + val room = client.findJoinedRoom(roomId) ?: return@map false + val mediaSender = mediaSenderRoomFactory.create(client = client, room = room) + filesToShare + .map { fileToShare -> + val result = mediaSender.sendMedia( + caption = shareIntentData.text, + uri = fileToShare.uri, + mimeType = fileToShare.mimeType, + mediaOptimizationConfig = mediaOptimizationConfig, + ) + // If the coroutine was cancelled, destroy the room and rethrow the exception + val cancellationException = result.exceptionOrNull() as? CancellationException + if (cancellationException != null) { + if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) { + room.destroy() + } + throw cancellationException + } + result.isSuccess + } + .all { isSuccess -> isSuccess } + .also { + if (activeRoomsHolder.getActiveRoomMatching(client.sessionId, roomId) == null) { + room.destroy() + } + } + } + .all { it } + } + } + } + + // Handle post-processing of shared data + onSharedData(shareIntentData) + + if (!result) { + error("Failed to handle incoming share intent") + } + } + + private suspend fun MatrixClient.findJoinedRoom(roomId: RoomId): JoinedRoom? { + return activeRoomsHolder.getActiveRoom(sessionId) + ?.takeIf { it.roomId == roomId } + ?: getJoinedRoom(roomId) + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt index 98d4acc472..3ebaf7a753 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -10,12 +10,12 @@ package io.element.android.features.share.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.features.share.api.ShareEntryPoint import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.di.SessionScope -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultShareEntryPoint : ShareEntryPoint { override fun createNode( parentNode: Node, @@ -26,7 +26,10 @@ class DefaultShareEntryPoint : ShareEntryPoint { return parentNode.createNode( buildContext = buildContext, plugins = listOf( - ShareNode.Inputs(shareIntentData = params.shareIntentData), + ShareNode.Inputs( + sessionId = params.sessionId, + shareIntentData = params.shareIntentData, + ), callback, ) ) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt new file mode 100644 index 0000000000..a4f4c34970 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareDataSender.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.share.impl + +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Sends the shared data to some rooms of a session. + */ +interface ShareDataSender { + /** + * Send [shareIntentData] to the rooms with [roomIds], using the session of [client]. + */ + suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt index 1abac1afa6..72f331abb2 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -18,6 +18,7 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.ParentNode 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 @@ -26,13 +27,13 @@ import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.parcelize.Parcelize -@ContributesNode(SessionScope::class) +@ContributesNode(AppScope::class) @AssistedInject class ShareNode( @Assisted buildContext: BuildContext, @@ -50,7 +51,10 @@ class ShareNode( @Parcelize object NavTarget : Parcelable - data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs + data class Inputs( + val sessionId: SessionId, + val shareIntentData: ShareIntentData, + ) : NodeInputs private val inputs = inputs() private val presenter = presenterFactory.create(inputs.shareIntentData) @@ -58,12 +62,16 @@ class ShareNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) { - presenter.onRoomSelected(roomIds) + override fun onRoomSelected(sessionId: SessionId, roomIds: List) { + presenter.onRoomSelected(sessionId, roomIds) } override fun onCancel() { - callback.onDone(emptyList()) + callback.onCancel() + } + + override fun onSessionVerificationRequired(sessionId: SessionId) { + callback.onSessionVerificationRequired(sessionId) } } @@ -71,6 +79,7 @@ class ShareNode( parentNode = this, buildContext = buildContext, params = RoomSelectEntryPoint.Params( + sessionId = inputs.sessionId, mode = RoomSelectMode.Share, maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, ), @@ -89,7 +98,7 @@ class ShareNode( val state = presenter.present() ShareView( state = state, - onShareSuccess = callback::onDone, + onShareSuccess = { result -> callback.onDone(result.sessionId, result.roomIds) }, ) } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt index 3bd9b98227..91961afb6a 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -14,43 +14,44 @@ import androidx.compose.runtime.mutableStateOf import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.share.api.OnSharedData import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.di.annotations.SessionCoroutineScope -import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import kotlin.coroutines.cancellation.CancellationException @AssistedInject class SharePresenter( @Assisted private val shareIntentData: ShareIntentData, - @SessionCoroutineScope - private val sessionCoroutineScope: CoroutineScope, - private val matrixClient: MatrixClient, - private val mediaSenderRoomFactory: MediaSenderRoomFactory, - private val activeRoomsHolder: ActiveRoomsHolder, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, - private val onSharedData: OnSharedData, + @AppCoroutineScope + private val appCoroutineScope: CoroutineScope, + private val matrixClientProvider: MatrixClientProvider, + private val shareDataSender: ShareDataSender, ) : Presenter { @AssistedFactory fun interface Factory { fun create(shareIntentData: ShareIntentData): SharePresenter } - private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) + private val shareActionState: MutableState> = mutableStateOf(AsyncAction.Uninitialized) - fun onRoomSelected(roomIds: List) { - sessionCoroutineScope.share(shareIntentData, roomIds) + /** + * Share the data to the rooms with [roomIds], which belong to the session [sessionId]. It can be another session than the current one. + */ + fun onRoomSelected(sessionId: SessionId, roomIds: List) { + if (shareActionState.value.isLoading()) return + appCoroutineScope.launch { + suspend { + val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() + shareDataSender.send(client, shareIntentData, roomIds).getOrThrow() + ShareResult(sessionId = sessionId, roomIds = roomIds) + }.runCatchingUpdatingState(shareActionState) + } } @Composable @@ -66,76 +67,4 @@ class SharePresenter( eventSink = ::handleEvent, ) } - - private suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? { - return activeRoomsHolder.getActiveRoom(matrixClient.sessionId) - ?.takeIf { it.roomId == roomId } - ?: matrixClient.getJoinedRoom(roomId) - } - - private fun CoroutineScope.share( - shareIntentData: ShareIntentData, - roomIds: List, - ) = launch { - suspend { - val result = when (shareIntentData) { - is ShareIntentData.PlainText -> { - roomIds - .map { roomId -> - getJoinedRoom(roomId)?.liveTimeline?.sendMessage( - body = shareIntentData.content, - htmlBody = null, - intentionalMentions = emptyList(), - )?.isSuccess.orFalse() - } - .all { it } - } - is ShareIntentData.Uris -> { - val filesToShare = shareIntentData.uris - if (filesToShare.isEmpty()) { - false - } else { - roomIds - .map { roomId -> - val room = getJoinedRoom(roomId) ?: return@map false - val mediaSender = mediaSenderRoomFactory.create(room = room) - filesToShare - .map { fileToShare -> - val result = mediaSender.sendMedia( - caption = shareIntentData.text, - uri = fileToShare.uri, - mimeType = fileToShare.mimeType, - mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), - ) - // If the coroutine was cancelled, destroy the room and rethrow the exception - val cancellationException = result.exceptionOrNull() as? CancellationException - if (cancellationException != null) { - if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { - room.destroy() - } - throw cancellationException - } - result.isSuccess - } - .all { isSuccess -> isSuccess } - .also { - if (activeRoomsHolder.getActiveRoomMatching(matrixClient.sessionId, roomId) == null) { - room.destroy() - } - } - } - .all { it } - } - } - } - - // Handle post-processing of shared data - onSharedData(shareIntentData) - - if (!result) { - error("Failed to handle incoming share intent") - } - roomIds - }.runCatchingUpdatingState(shareActionState) - } } diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt index 7538309941..eb50410526 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt @@ -10,8 +10,19 @@ package io.element.android.features.share.impl import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId data class ShareState( - val shareAction: AsyncAction>, + val shareAction: AsyncAction, val eventSink: (ShareEvent) -> Unit ) + +/** + * The result of a successful share. + * @param sessionId the session which has been used to share the data, it can be another session than the current one. + * @param roomIds the rooms the data has been shared to. + */ +data class ShareResult( + val sessionId: SessionId, + val roomIds: List, +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt index 18258f1b95..38ae8369df 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStatePreviewParam.kt @@ -11,6 +11,7 @@ package io.element.android.features.share.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId open class ShareStatePreviewParam : PreviewParameterProvider { override val values: Sequence @@ -20,9 +21,7 @@ open class ShareStatePreviewParam : PreviewParameterProvider { shareAction = AsyncAction.Loading, ), aShareState( - shareAction = AsyncAction.Success( - listOf(RoomId("!room2:domain")), - ) + shareAction = AsyncAction.Success(aShareResult()) ), aShareState( shareAction = AsyncAction.Failure(RuntimeException("error")), @@ -30,10 +29,18 @@ open class ShareStatePreviewParam : PreviewParameterProvider { ) } -fun aShareState( - shareAction: AsyncAction> = AsyncAction.Uninitialized, +private fun aShareState( + shareAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (ShareEvent) -> Unit = {} ) = ShareState( shareAction = shareAction, eventSink = eventSink ) + +private fun aShareResult( + sessionId: SessionId = SessionId("@alice:server.org"), + roomIds: List = listOf(RoomId("!room2:domain")), +) = ShareResult( + sessionId = sessionId, + roomIds = roomIds, +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt index 2516c31a66..f2d91972cf 100644 --- a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt @@ -13,12 +13,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.matrix.api.core.RoomId @Composable fun ShareView( state: ShareState, - onShareSuccess: (List) -> Unit, + onShareSuccess: (ShareResult) -> Unit, ) { AsyncActionView( async = state.shareAction, diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt new file mode 100644 index 0000000000..bdd99f6a4d --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareDataSenderTest.kt @@ -0,0 +1,119 @@ +/* + * 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.share.impl + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.element.android.features.share.api.OnSharedData +import io.element.android.features.share.api.ShareIntentData +import io.element.android.features.share.api.UriToShare +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.test.FakeMediaSender +import io.element.android.services.appnavstate.api.ActiveRoomsHolder +import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultShareDataSenderTest : RobolectricTest() { + @Test + fun `send text ok`() = runTest { + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } + }, + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val onSharedData = lambdaRecorder { } + val sender = createDefaultShareDataSender( + onSharedData = { onSharedData(it) }, + ) + val result = sender.send(matrixClient, ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID)) + assertThat(result.isSuccess).isTrue() + onSharedData.assertions().isCalledOnce() + } + + @Test + fun `send text, room not found`() = runTest { + val onSharedData = lambdaRecorder { } + val sender = createDefaultShareDataSender( + onSharedData = { onSharedData(it) }, + ) + val result = sender.send(FakeMatrixClient(), ShareIntentData.PlainText(A_MESSAGE), listOf(A_ROOM_ID)) + assertThat(result.isFailure).isTrue() + onSharedData.assertions().isCalledOnce() + } + + @Test + fun `send media ok`() = runTest { + val sendMediaResult = lambdaRecorder> { Result.success(Unit) } + val joinedRoom = FakeJoinedRoom( + liveTimeline = FakeTimeline(), + ) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, joinedRoom) + } + val mediaSender = FakeMediaSender( + sendMediaResult = sendMediaResult, + ) + val sender = createDefaultShareDataSender( + mediaSenderRoomFactory = { _, _ -> mediaSender }, + ) + val result = sender.send( + client = matrixClient, + shareIntentData = ShareIntentData.Uris( + text = A_MESSAGE, + listOf( + UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, + ) + ) + ), + roomIds = listOf(A_ROOM_ID), + ) + assertThat(result.isSuccess).isTrue() + sendMediaResult.assertions().isCalledOnce() + } + + @Test + fun `send media with no uris is a failure`() = runTest { + val sender = createDefaultShareDataSender() + val result = sender.send( + client = FakeMatrixClient(), + shareIntentData = ShareIntentData.Uris(text = A_MESSAGE, emptyList()), + roomIds = listOf(A_ROOM_ID), + ) + assertThat(result.isFailure).isTrue() + } +} + +private fun createDefaultShareDataSender( + mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { _, _ -> FakeMediaSender() }, + mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory = MediaOptimizationConfigProviderFactory { + FakeMediaOptimizationConfigProvider() + }, + activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), + onSharedData: OnSharedData = OnSharedData {}, +) = DefaultShareDataSender( + mediaSenderRoomFactory = mediaSenderRoomFactory, + mediaOptimizationConfigProviderFactory = mediaOptimizationConfigProviderFactory, + activeRoomsHolder = activeRoomsHolder, + onSharedData = onSharedData, +) diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt index 459fa423ec..62c78d5a66 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/DefaultShareEntryPointTest.kt @@ -15,7 +15,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.share.api.ShareIntentData import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.roomselect.test.FakeRoomSelectEntryPoint import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode @@ -42,9 +44,12 @@ class DefaultShareEntryPointTest { ) } val callback = object : ShareEntryPoint.Callback { - override fun onDone(roomIds: List) = lambdaError() + override fun onDone(sessionId: SessionId, roomIds: List) = lambdaError() + override fun onCancel() = lambdaError() + override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError() } val params = ShareEntryPoint.Params( + sessionId = A_SESSION_ID, shareIntentData = ShareIntentData.PlainText(A_MESSAGE), ) val result = entryPoint.createNode( @@ -54,7 +59,7 @@ class DefaultShareEntryPointTest { callback = callback, ) assertThat(result).isInstanceOf(ShareNode::class.java) - assertThat(result.plugins).contains(ShareNode.Inputs(params.shareIntentData)) + assertThat(result.plugins).contains(ShareNode.Inputs(params.sessionId, params.shareIntentData)) assertThat(result.plugins).contains(callback) } } diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.kt new file mode 100644 index 0000000000..370ed8b31c --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareDataSender.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.share.impl + +import io.element.android.features.share.api.ShareIntentData +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeShareDataSender( + private val sendResult: (MatrixClient, ShareIntentData, List) -> Result = { _, _, _ -> lambdaError() }, +) : ShareDataSender { + override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result { + return sendResult(client, shareIntentData, roomIds) + } +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt index 44ccea92d6..a2575d8186 100644 --- a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -8,37 +8,32 @@ package io.element.android.features.share.impl -import android.net.Uri 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.share.api.OnSharedData import io.element.android.features.share.api.ShareIntentData -import io.element.android.features.share.api.UriToShare import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeJoinedRoom -import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.api.MediaSenderRoomFactory -import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider -import io.element.android.libraries.mediaupload.test.FakeMediaSender -import io.element.android.services.appnavstate.api.ActiveRoomsHolder -import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class SharePresenterTest : RobolectricTest() { +class SharePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -55,16 +50,18 @@ class SharePresenterTest : RobolectricTest() { @Test fun `present - on room selected error then clear error`() = runTest { - val presenter = createSharePresenter() + val presenter = createSharePresenter( + shareDataSender = FakeShareDataSender { _, _, _ -> Result.failure(AN_EXCEPTION) }, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val failure = awaitItem() - assertThat(failure.shareAction.isFailure()).isTrue() + assertThat(failure.shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) failure.eventSink.invoke(ShareEvent.ClearError) assertThat(awaitItem().shareAction.isUninitialized()).isTrue() } @@ -72,113 +69,106 @@ class SharePresenterTest : RobolectricTest() { @Test fun `present - on room selected ok`() = runTest { - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } - }, - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) - } + val client = FakeMatrixClient(sessionId = A_SESSION_ID) + val shareIntentData = ShareIntentData.PlainText(A_MESSAGE) + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.PlainText(A_MESSAGE), + shareIntentData = shareIntentData, + matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() - assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID)))) + sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID))) } } @Test - fun `present - send text ok`() = runTest { - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendMessageLambda = { _, _, _, _, _ -> Result.success(Unit) } - }, - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) + fun `present - on room selected from another session ok`() = runTest { + val getClient = lambdaRecorder> { sessionId -> Result.success(FakeMatrixClient(sessionId = sessionId)) } + val sendResult = lambdaRecorder, Result> { client, _, _ -> + assertThat(client.sessionId).isEqualTo(A_SESSION_ID_2) + Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.PlainText(A_MESSAGE), + matrixClientProvider = FakeMatrixClientProvider { getClient(it) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + skipItems(1) + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() - assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID_2, listOf(A_ROOM_ID)))) + getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + sendResult.assertions().isCalledOnce() } } @Test - fun `present - send media ok`() = runTest { - val sendMediaResult = lambdaRecorder> { Result.success(Unit) } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline(), - ) - val matrixClient = FakeMatrixClient().apply { - givenGetRoomResult(A_ROOM_ID, joinedRoom) - } - val mediaSender = FakeMediaSender( - sendMediaResult = sendMediaResult, - ) + fun `present - on room selected, failure to get the client`() = runTest { + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } val presenter = createSharePresenter( - matrixClient = matrixClient, - shareIntentData = ShareIntentData.Uris( - text = A_MESSAGE, - listOf( - UriToShare( - uri = Uri.parse("content://image.jpg"), - mimeType = MimeTypes.Jpeg, - ) - ) - ), - mediaSenderRoomFactory = MediaSenderRoomFactory { mediaSender }, + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + shareDataSender = FakeShareDataSender(sendResult), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.shareAction.isUninitialized()).isTrue() - presenter.onRoomSelected(listOf(A_ROOM_ID)) + skipItems(1) + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) assertThat(awaitItem().shareAction.isLoading()).isTrue() + assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + sendResult.assertions().isNeverCalled() + } + } + + @Test + fun `present - on room selected while sharing is ignored`() = runTest { + val completable = CompletableDeferred>() + val sendResult = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val presenter = createSharePresenter( + shareDataSender = object : ShareDataSender { + override suspend fun send(client: MatrixClient, shareIntentData: ShareIntentData, roomIds: List): Result { + sendResult(client, shareIntentData, roomIds) + return completable.await() + } + }, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID)) + completable.complete(Result.success(Unit)) val success = awaitItem() - assertThat(success.shareAction.isSuccess()).isTrue() - assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) - sendMediaResult.assertions().isCalledOnce() + // The second selection has been ignored + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID)))) + sendResult.assertions().isCalledOnce() } } } internal fun TestScope.createSharePresenter( shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE), - matrixClient: MatrixClient = FakeMatrixClient(), - activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(), - mediaSenderRoomFactory: MediaSenderRoomFactory = MediaSenderRoomFactory { FakeMediaSender() }, - mediaOptimizationConfigProvider: MediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - onSharedData: OnSharedData = OnSharedData {}, + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + shareDataSender: ShareDataSender = FakeShareDataSender(), ): SharePresenter { return SharePresenter( shareIntentData = shareIntentData, - sessionCoroutineScope = this, - matrixClient = matrixClient, - activeRoomsHolder = activeRoomsHolder, - mediaSenderRoomFactory = mediaSenderRoomFactory, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, - onSharedData = onSharedData, + appCoroutineScope = this, + matrixClientProvider = matrixClientProvider, + shareDataSender = shareDataSender, ) } diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts index e4a987c113..6b8f8f7572 100644 --- a/features/share/test/build.gradle.kts +++ b/features/share/test/build.gradle.kts @@ -15,7 +15,5 @@ android { dependencies { implementation(projects.features.share.api) - implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) } diff --git a/features/signedout/impl/build.gradle.kts b/features/signedout/impl/build.gradle.kts index b3801288be..ed5dec3eb9 100644 --- a/features/signedout/impl/build.gradle.kts +++ b/features/signedout/impl/build.gradle.kts @@ -16,6 +16,12 @@ plugins { android { namespace = "io.element.android.features.signedout.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } setupDependencyInjection() @@ -30,7 +36,7 @@ dependencies { implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) - testCommonDependencies(libs) + testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt index 1e774488e5..39e22ad434 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutEvent.kt @@ -9,5 +9,5 @@ package io.element.android.features.signedout.impl sealed interface SignedOutEvent { - data object SignInAgain : SignedOutEvent + data object Submit : SignedOutEvent } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt index 1f1a9be1d7..3f9a3b3e4c 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutPresenter.kt @@ -17,17 +17,18 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch @AssistedInject class SignedOutPresenter( @Assisted private val sessionId: SessionId, private val sessionStore: SessionStore, - private val buildMeta: BuildMeta, ) : Presenter { @AssistedFactory fun interface Factory { @@ -37,23 +38,23 @@ class SignedOutPresenter( @Composable override fun present(): SignedOutState { val signedOutSession by remember { - sessionStore.sessionsFlow().map { sessions -> - sessions.firstOrNull { it.userId == sessionId.value } - } - }.collectAsState(initial = null) + sessionStore.sessionsFlow() + // Ignore the removal of the session, to keep rendering the account data until the screen is closed + .mapNotNull { sessions -> sessions.firstOrNull { it.userId == sessionId.value } } + .map { it.toMatrixUser() } + }.collectAsState(initial = MatrixUser(userId = sessionId)) val coroutineScope = rememberCoroutineScope() fun handleEvent(event: SignedOutEvent) { when (event) { - SignedOutEvent.SignInAgain -> coroutineScope.launch { + SignedOutEvent.Submit -> coroutineScope.launch { sessionStore.removeSession(sessionId.value) } } } return SignedOutState( - appName = buildMeta.applicationName, - signedOutSession = signedOutSession, + signedOutMatrixUser = signedOutSession, eventSink = ::handleEvent, ) } diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt index aeff7c7d2f..c6c4d2a2c9 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutState.kt @@ -8,10 +8,9 @@ package io.element.android.features.signedout.impl -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.MatrixUser data class SignedOutState( - val appName: String, - val signedOutSession: SessionData?, + val signedOutMatrixUser: MatrixUser, val eventSink: (SignedOutEvent) -> Unit, ) diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt index ae60b7aacb..d83ebfe7d7 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt @@ -9,43 +9,21 @@ package io.element.android.features.signedout.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.sessionstorage.api.LoginType -import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser open class SignedOutStatePreviewParam : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aSignedOutState(), - // Add other states here + aSignedOutState(signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null)), ) } -private fun aSignedOutState() = SignedOutState( - appName = "AppName", - signedOutSession = aSessionData(), - eventSink = {}, +internal fun aSignedOutState( + signedOutSession: MatrixUser = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink: (SignedOutEvent) -> Unit = {}, +) = SignedOutState( + signedOutMatrixUser = signedOutSession, + eventSink = eventSink, ) - -private fun aSessionData( - sessionId: String = "@alice:server.org", - isTokenValid: Boolean = false, -): SessionData { - return SessionData( - userId = sessionId, - deviceId = "aDeviceId", - accessToken = "anAccessToken", - refreshToken = "aRefreshToken", - homeserverUrl = "aHomeserverUrl", - oAuthData = null, - loginTimestamp = null, - isTokenValid = isTokenValid, - loginType = LoginType.UNKNOWN, - passphrase = null, - sessionPath = "/a/path/to/a/session", - cachePath = "/a/path/to/a/cache", - position = 0, - lastUsageIndex = 0, - userDisplayName = null, - userAvatarUrl = null, - ) -} diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt index 73e854c836..40ab488f11 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutView.kt @@ -9,14 +9,14 @@ package io.element.android.features.signedout.impl import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable -import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter @@ -31,58 +31,75 @@ import io.element.android.libraries.designsystem.components.visuallist.VisualLis import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.persistentListOf +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=856-6104 + */ @Composable fun SignedOutView( state: SignedOutState, modifier: Modifier = Modifier, ) { - BackHandler(onBack = { state.eventSink(SignedOutEvent.SignInAgain) }) + BackHandler(onBack = { state.eventSink(SignedOutEvent.Submit) }) HeaderFooterPage( modifier = modifier .fillMaxSize() .systemBarsPadding() .imePadding(), - header = { SignedOutHeader(state) }, - content = { SignedOutContent() }, + isScrollable = true, + header = { SignedOutHeader() }, + content = { SignedOutContent(state.signedOutMatrixUser) }, footer = { SignedOutFooter( - onSignInAgain = { state.eventSink(SignedOutEvent.SignInAgain) }, + onSubmit = { state.eventSink(SignedOutEvent.Submit) }, ) } ) } @Composable -private fun SignedOutHeader(state: SignedOutState) { +private fun SignedOutHeader() { IconTitleSubtitleMolecule( modifier = Modifier.padding(top = 60.dp, bottom = 12.dp), title = stringResource(id = R.string.screen_signed_out_title), - subTitle = stringResource(id = R.string.screen_signed_out_subtitle, state.appName), - iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), + subTitle = stringResource(id = R.string.screen_signed_out_subtitle_v2), + iconStyle = BigIcon.Style.Default( + vectorIcon = CompoundIcons.Devices(), + ), ) } @Composable -private fun SignedOutContent() { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = BiasAlignment( - horizontalBias = 0f, - verticalBias = -0.4f - ) +private fun SignedOutContent( + signedOutMatrixUser: MatrixUser, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), ) { + AccountInfoCard( + AccountInfoCardState( + matrixUser = signedOutMatrixUser, + mode = AccountInfoCardMode.Simple, + ) + ) VisualList( items = persistentListOf( VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_1), - iconVector = CompoundIcons.Lock(), + iconVector = CompoundIcons.UserProfile(), ), VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_2), - iconVector = CompoundIcons.Devices(), + iconVector = CompoundIcons.Lock(), ), VisualListItemData( message = stringResource(id = R.string.screen_signed_out_reason_3), @@ -95,12 +112,12 @@ private fun SignedOutContent() { @Composable private fun SignedOutFooter( - onSignInAgain: () -> Unit, + onSubmit: () -> Unit, ) { ButtonColumnMolecule { Button( - text = stringResource(id = CommonStrings.action_sign_in_again), - onClick = onSignInAgain, + text = stringResource(id = CommonStrings.action_ok), + onClick = onSubmit, modifier = Modifier.fillMaxWidth(), ) } diff --git a/features/signedout/impl/src/main/res/values-fr/translations.xml b/features/signedout/impl/src/main/res/values-fr/translations.xml index e093df8517..5cd17b5dc0 100644 --- a/features/signedout/impl/src/main/res/values-fr/translations.xml +++ b/features/signedout/impl/src/main/res/values-fr/translations.xml @@ -1,8 +1,9 @@ - "Le mot de passe de votre compte a été modifié sur un autre appareil" - "Cette session a été supprimée depuis un autre appareil" - "L’administrateur de votre serveur a révoqué votre accès." + "Vous avez supprimé cet appareil de votre compte." + "Vous avez modifié votre mot de passe sur un autre appareil" + "Votre session a expiré ou l’administrateur de votre serveur a révoqué votre accès." "La déconnexion peut être due à une des raisons ci-dessous. Veuillez vous connecter à nouveau pour continuer à utiliser %s." - "Vous avez été déconnecté" + "Cet appareil a été supprimé pour l’une des raisons indiquées ci-dessous. Vous devez vous reconnecter pour associer cet appareil à votre compte." + "Appareil supprimé" diff --git a/features/signedout/impl/src/main/res/values-hr/translations.xml b/features/signedout/impl/src/main/res/values-hr/translations.xml index 17af7626b1..8318146cf4 100644 --- a/features/signedout/impl/src/main/res/values-hr/translations.xml +++ b/features/signedout/impl/src/main/res/values-hr/translations.xml @@ -1,8 +1,9 @@ - "Promijenili ste zaporku u drugoj sesiji" - "Izbrisali ste sesiju iz druge sesije" + "Uklonili ste ovaj uređaj sa svog računa" + "Promijenili ste lozinku na drugom uređaju" "Administrator vašeg poslužitelja poništio je vaš pristup" "Možda ste odjavljeni iz jednog od razloga navedenih u nastavku. Ponovno se prijavite kako biste nastavili koristiti %s." - "Odjavljeni ste" + "Ovaj je uređaj uklonjen iz jednog od dolje navedenih razloga. Ponovno se prijavite kako biste povezali ovaj uređaj sa svojim računom." + "Uređaj uklonjen" diff --git a/features/signedout/impl/src/main/res/values-hu/translations.xml b/features/signedout/impl/src/main/res/values-hu/translations.xml index 2b8bd3528e..c83b808cfd 100644 --- a/features/signedout/impl/src/main/res/values-hu/translations.xml +++ b/features/signedout/impl/src/main/res/values-hu/translations.xml @@ -1,8 +1,9 @@ - "Megváltoztatta a jelszavát egy másik munkamenetben" - "Törölte a munkamenetet egy másik munkamenetből" - "A kiszolgáló adminisztrátora érvénytelenítette a hozzáférését" + "Ezt az eszközt eltávolította a fiókjából" + "Jelszavát egy másik eszközön módosította" + "A szerver munkamenete lejárt, vagy a rendszergazda letiltotta a hozzáférését" "Előfordulhat, hogy az alábbiakban felsorolt okok valamelyike miatt került kijelentkeztetésre. Jelentkezzen be újra, hogy folytatni tudja az %s használatát." - "Kijelentkezett" + "Ezt az eszközt az alább felsorolt ​​okok egyike miatt eltávolítottuk. Kérjük, jelentkezzen be újra, az eszközt fiókjához való csatlakoztatásához." + "Eszköz eltávolítva" diff --git a/features/signedout/impl/src/main/res/values-in/translations.xml b/features/signedout/impl/src/main/res/values-in/translations.xml index 7feb4b4edb..2d46173d7d 100644 --- a/features/signedout/impl/src/main/res/values-in/translations.xml +++ b/features/signedout/impl/src/main/res/values-in/translations.xml @@ -4,5 +4,5 @@ "Anda telah menghapus sesi dari sesi lain" "Administrator homeserver Anda telah membatalkan akses Anda" "Anda mungkin dikeluarkan karena alasan berikut. Silakan masuk lagi untuk melanjutkan menggunakan %s." - "Anda telah keluar" + "Perangkat dihapus" diff --git a/features/signedout/impl/src/main/res/values-pl/translations.xml b/features/signedout/impl/src/main/res/values-pl/translations.xml index 9b83e7d692..7da7ecc2f0 100644 --- a/features/signedout/impl/src/main/res/values-pl/translations.xml +++ b/features/signedout/impl/src/main/res/values-pl/translations.xml @@ -1,8 +1,9 @@ - "Zmieniono hasło w innej sesji" - "Sesja została usunięta z innej sesji" - "Administrator serwera unieważnił Twój dostęp" + "Usunięto urządzenie ze swojego konta" + "Hasło zostało zmienione na innym urządzeniu" + "Sesja serwera wygasła lub administrator zablokował Twój dostęp" "Mogłeś zostać wylogowany z powodów wymienionych poniżej. Zaloguj się ponownie, aby dalej korzystać z %s." - "Zostałeś wylogowany" + "Urządzenie zostało usunięte przez jedną z poniższych przyczyn. Zaloguj się ponownie, aby powiązać to urządzenie ze swoim kontem." + "Urządzenie usunięte" diff --git a/features/signedout/impl/src/main/res/values-ro/translations.xml b/features/signedout/impl/src/main/res/values-ro/translations.xml index 4ece7a14a3..6f913a3f06 100644 --- a/features/signedout/impl/src/main/res/values-ro/translations.xml +++ b/features/signedout/impl/src/main/res/values-ro/translations.xml @@ -1,8 +1,9 @@ - "V-ați schimbat parola într-o altă sesiune" - "Ați șters sesiunea dintr-o altă sesiune" - "Administratorul serverului dumneavoastra v-a invalidat accesul" - "Este posibil să fi fost deconectat din unul dintre motivele enumerate mai jos. Vă rugăm să vă conectați din nou pentru a continua utilizarea%s." - "Sunteți deconectat" + "Ați eliminat acest dispozitiv din contul dvs." + "V-ați schimbat parola pe un alt dispozitiv" + "Sesiunea de pe server a expirat sau administratorul v-a blocat accesul" + "Este posibil ca deconectarea să fi avut loc din unul dintre motivele de mai jos. Conectați din nou pentru a continua utilizarea %s." + "Acest dispozitiv a fost eliminat din unul dintre motivele enumerate mai jos. Vă rugăm să vă conectați din nou pentru a reconecta acest dispozitiv la contul dvs." + "Dispozitiv eliminat" diff --git a/features/signedout/impl/src/main/res/values-uk/translations.xml b/features/signedout/impl/src/main/res/values-uk/translations.xml index ebf364518b..8360646986 100644 --- a/features/signedout/impl/src/main/res/values-uk/translations.xml +++ b/features/signedout/impl/src/main/res/values-uk/translations.xml @@ -1,8 +1,9 @@ - "Ви змінили пароль під час іншого сеансу" - "Ви видалили сеанс з іншого сеансу" - "Адміністратор вашого сервера визнав ваш доступ недійсним" + "Ви вилучили цей пристрій зі свого облікового запису" + "Ви змінили пароль на іншому пристрої" + "Термін дії сеансу на вашому сервері закінчився або адміністратор заблокував ваш доступ" "Можливо, ви вийшли з системи з однієї з причин, наведених нижче. Будь ласка, увійдіть знову, щоб продовжити використання %s." - "Ви вийшли з системи" + "Цей пристрій було видалено з однієї з наведених нижче причин. Будь ласка, увійдіть знову, щоб підключити цей пристрій до вашого облікового запису." + "Пристрій вилучено" diff --git a/features/signedout/impl/src/main/res/values-zh/translations.xml b/features/signedout/impl/src/main/res/values-zh/translations.xml index 6768608294..6c9b5a4bbe 100644 --- a/features/signedout/impl/src/main/res/values-zh/translations.xml +++ b/features/signedout/impl/src/main/res/values-zh/translations.xml @@ -1,8 +1,9 @@ - "你在另一个会话中更改了密码" - "你已从其它会话中删除此会话" - "服务器管理员已禁止你的访问" + "你已为账户移除此设备" + "你已在另一设备上更改密码" + "你的服务器会话已过期,或管理员已阻止你的访问。" "你可能因以下原因而被注销。请重新登录以继续使用 %s。" - "你已注销" + "此设备因以下原因之一已被移除。请重新登录以连接设备到你的账户。" + "设备已移除" diff --git a/features/signedout/impl/src/main/res/values/localazy.xml b/features/signedout/impl/src/main/res/values/localazy.xml index f70b0042d5..7b221a18f2 100644 --- a/features/signedout/impl/src/main/res/values/localazy.xml +++ b/features/signedout/impl/src/main/res/values/localazy.xml @@ -1,8 +1,9 @@ - "You’ve changed your password on another session" - "You have deleted the session from another session" - "Your server’s administrator has invalidated your access" + "You removed this device from your account" + "You’ve changed your password on another device" + "Your server\'s session expired or the administrator has blocked your access" "You might have been signed out for one of the reasons listed below. Please sign in again to continue using %s." - "You’re signed out" + "This device was removed for one of the reasons listed below. Please sign in again to connect this device to your account." + "Device removed" diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt index b72ebbcdba..5f15c9473c 100644 --- a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutPresenterTest.kt @@ -13,13 +13,14 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_SESSION_ID -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.libraries.sessionstorage.test.aSessionData import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -30,7 +31,12 @@ class SignedOutPresenterTest { @Test fun `present - initial state`() = runTest { - val aSessionData = aSessionData() + val aSessionData = aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = "Alice", + userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", + ) val sessionStore = InMemorySessionStore( initialList = listOf(aSessionData) ) @@ -38,16 +44,26 @@ class SignedOutPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) val initialState = awaitItem() - assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) - assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID)) + assertThat(awaitItem().signedOutMatrixUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = "Alice", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - sign in again`() = runTest { - val aSessionData = aSessionData() + val aSessionData = aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = "Alice", + ) val sessionStore = InMemorySessionStore( initialList = listOf(aSessionData) ) @@ -57,13 +73,15 @@ class SignedOutPresenterTest { }.test { skipItems(1) val initialState = awaitItem() - assertThat(initialState.signedOutSession).isEqualTo(aSessionData) + assertThat(initialState.signedOutMatrixUser).isEqualTo(MatrixUser(userId = A_SESSION_ID, displayName = "Alice")) assertThat(sessionStore.getAllSessions()).isNotEmpty() assertThat(sessionStore.numberOfSessions()).isEqualTo(1) - initialState.eventSink(SignedOutEvent.SignInAgain) - assertThat(awaitItem().signedOutSession).isNull() + initialState.eventSink(SignedOutEvent.Submit) + runCurrent() assertThat(sessionStore.getAllSessions()).isEmpty() assertThat(sessionStore.numberOfSessions()).isEqualTo(0) + // The removal of the session must not change the rendered data + expectNoEvents() } } } @@ -75,6 +93,5 @@ internal fun createSignedOutPresenter( return SignedOutPresenter( sessionId = sessionId, sessionStore = sessionStore, - buildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), ) } diff --git a/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt new file mode 100644 index 0000000000..f9c2438ea3 --- /dev/null +++ b/features/signedout/impl/src/test/kotlin/io/element/android/features/signedout/impl/SignedOutViewTest.kt @@ -0,0 +1,86 @@ +/* + * 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.features.signedout.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.matrix.ui.components.aMatrixUser +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.pressBackKey +import io.element.android.tests.testutils.robolectric.RobolectricTest +import org.junit.Test +import org.robolectric.annotation.Config + +class SignedOutViewTest : RobolectricTest() { + @Test + @Config(qualifiers = "h1024dp") + fun `the signed out user is displayed`() = runAndroidComposeUiTest { + setSignedOutView( + aSignedOutState( + signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = "Alice"), + eventSink = EventsRecorder(expectEvents = false), + ), + ) + onNodeWithText("Alice").assertIsDisplayed() + onNodeWithText("@alice:server.org").assertIsDisplayed() + } + + @Test + @Config(qualifiers = "h1024dp") + fun `the signed out user id is displayed when there is no display name`() = runAndroidComposeUiTest { + setSignedOutView( + aSignedOutState( + signedOutSession = aMatrixUser(id = "@alice:server.org", displayName = null), + eventSink = EventsRecorder(expectEvents = false), + ), + ) + onNodeWithText("@alice:server.org").assertIsDisplayed() + } + + @Test + fun `clicking on OK sends a Submit event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setSignedOutView( + aSignedOutState( + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(SignedOutEvent.Submit) + } + + @Test + fun `pressing back sends a Submit event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setSignedOutView( + aSignedOutState( + eventSink = eventsRecorder, + ), + ) + pressBackKey() + eventsRecorder.assertSingle(SignedOutEvent.Submit) + } +} + +private fun AndroidComposeUiTest.setSignedOutView( + state: SignedOutState, +) { + setContent { + SignedOutView( + state = state, + ) + } +} diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts index deec862fa3..57bb0f7122 100644 --- a/features/space/impl/build.gradle.kts +++ b/features/space/impl/build.gradle.kts @@ -34,10 +34,8 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.deeplink.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) - implementation(projects.libraries.featureflag.api) implementation(projects.features.createroom.api) implementation(projects.features.invite.api) implementation(projects.libraries.previewutils) @@ -49,7 +47,6 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.createroom.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.rolesandpermissions.test) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt index e3698aacb2..486ead4304 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.space.impl import android.os.Parcelable -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope 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 4db17634d6..9205587a4f 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 @@ -25,7 +25,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -61,7 +60,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddRoomToSpaceView( state: AddRoomToSpaceState, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt index e9e13c2c0e..296e72cb86 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/leave/LeaveSpaceView.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.features.space.impl.leave import androidx.annotation.StringRes @@ -23,7 +21,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index 069b00492c..839a76a9a9 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -96,7 +95,6 @@ import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SpaceView( state: SpaceState, @@ -359,7 +357,7 @@ private fun EmptySpaceView( IconTitleSubtitleMolecule( title = stringResource(R.string.screen_space_empty_state_title), subTitle = null, - iconStyle = BigIcon.Style.Default(vectorIcon = CompoundIcons.Room(), usePrimaryTint = true), + iconStyle = BigIcon.Style.Default(vectorIcon = CompoundIcons.Room()), modifier = Modifier .fillMaxWidth() .padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = 24.dp), @@ -402,7 +400,6 @@ private fun LoadingMoreIndicator( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceViewTopBar( spaceInfo: RoomInfo, @@ -519,7 +516,6 @@ private fun SpaceViewTopBar( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ManageModeTopBar( selectedCount: Int, 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 b5cfc7702a..bb1b61dbdf 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 @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -150,7 +149,6 @@ private fun Section( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceSettingsTopBar( onBackClick: () -> Unit, diff --git a/features/space/impl/src/main/res/values-ro/translations.xml b/features/space/impl/src/main/res/values-ro/translations.xml index 7748f44ee2..456abcd5f5 100644 --- a/features/space/impl/src/main/res/values-ro/translations.xml +++ b/features/space/impl/src/main/res/values-ro/translations.xml @@ -5,18 +5,18 @@ "Părăsiți %1$d cameră și spațiul" "Părăsiți %1$d camere și spațiul" - "Părăsiți %1$d camere și spațiul" + "Părăsiți %1$d de camere și spațiul" "Selectați camerele pe care doriți să le părăsiți și în care nu sunteți singurul administrator:" "Trebuie să desemnați un alt administrator pentru acest spațiu înainte de a-l părăsi." - "Sunteți singurul proprietar al %1$s. Trebuie să transferați dreptul de proprietate către altcineva înainte de a parăsi camera." + "Sunteți singurul proprietar al %1$s. Trebuie să transferați dreptul de proprietate către altcineva înainte de a părăsi camera." "Nu veți părăsi următoarele camere deoarece sunteți singurul administrator:" "Părăsiți %1$s?" "Sunteți singurul administrator pentru %1$s" "Transferați proprietatea" "Cameră" "Adăugarea unei camere nu va afecta accesul la cameră. Pentru a modifica accesul, accesați Setări cameră > Securitate și confidențialitate." - "Adăugați prima dumneavoastră cameră" + "Adăugați prima dvs. cameră" "Vizualizați membrii" "Eliminarea unei camere nu va afecta accesul la aceasta. Pentru a modifica accesul, accesați Informații despre cameră > Confidențialitate și securitate." @@ -26,5 +26,5 @@ "Părăsiți spațiul" "Roluri și permisiuni" - "Securitate & confidențialitate" + "Securitate și confidențialitate" diff --git a/features/space/impl/src/main/res/values-zh-rTW/translations.xml b/features/space/impl/src/main/res/values-zh-rTW/translations.xml index 623f30923e..66e41bdb07 100644 --- a/features/space/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/space/impl/src/main/res/values-zh-rTW/translations.xml @@ -16,7 +16,7 @@ "新增聊天室不會影響聊天室存取權。要變更存取權,請前往「聊天室設定」→「安全性與隱私權」" "新增您的第一個聊天室" "檢視成員" - "移除聊天室不會影響聊天室存取權。要變更存取權,請前往「聊天室資訊」→「隱私權與安全性」。" + "移除聊天室不會影響聊天室存取權。要變更存取權,請前往「聊天室資訊」→「安全性與隱私權」。" "從 %2$s 移除 %1$d 個聊天室" diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index c0ec58d267..41fef18a3f 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -12,8 +12,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.changeroommemberroles.test.FakeChangeRoomMemberRolesEntryPoint -import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.createroom.test.FakeCreateRoomEntryPoint +import io.element.android.features.rolesandpermissions.test.FakeChangeRoomMemberRolesEntryPoint import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.FakeSpaceFlowGraph import io.element.android.libraries.matrix.api.core.RoomId diff --git a/features/startchat/impl/build.gradle.kts b/features/startchat/impl/build.gradle.kts index fe7bfa9ae2..1b04c175e1 100644 --- a/features/startchat/impl/build.gradle.kts +++ b/features/startchat/impl/build.gradle.kts @@ -35,13 +35,9 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.deeplink.api) - implementation(projects.libraries.mediapickers.api) - implementation(projects.libraries.mediaupload.api) - implementation(projects.libraries.permissions.api) implementation(projects.libraries.usersearch.api) implementation(projects.services.analytics.api) implementation(libs.coil.compose) - implementation(projects.libraries.featureflag.api) implementation(projects.features.createroom.api) implementation(projects.features.enterprise.api) api(projects.features.startchat.api) @@ -49,12 +45,8 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.services.analytics.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.mediapickers.test) - testImplementation(projects.libraries.mediaupload.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.usersearch.test) testImplementation(projects.features.createroom.test) testImplementation(projects.features.startchat.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.enterprise.test) } diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt index 9cb8d48778..4f19f0e573 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatFlowNode.kt @@ -22,7 +22,6 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.createroom.api.CreateRoomEntryPoint -import io.element.android.features.startchat.DefaultStartChatNavigator import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.features.startchat.impl.joinbyaddress.JoinRoomByAddressNode import io.element.android.features.startchat.impl.root.StartChatNode diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt similarity index 97% rename from features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt rename to features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt index 125dc2c2b3..2bd2ac2f1b 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/StartChatNavigator.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/StartChatNavigator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.startchat +package io.element.android.features.startchat.impl import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt index c8284c6a4f..4959a6f33b 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/components/SearchUserBar.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable @@ -38,7 +37,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.usersearch.api.UserSearchResult import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchUserBar( queryState: TextFieldState, diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt index c81cb1af66..acfe51ff92 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressNode.kt @@ -17,7 +17,7 @@ import com.bumble.appyx.core.plugin.plugins import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt index 533f680674..be8b96c21a 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinRoomByAddressPresenter.kt @@ -19,7 +19,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.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.matrix.api.MatrixClient diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt index 60ef9ee00f..69d82ad37c 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatNode.kt @@ -21,7 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.annotations.ContributesNode -import io.element.android.features.startchat.StartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.deeplink.api.usecase.InviteFriendsUseCase import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias diff --git a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt index 3ef807b777..d1f91cb837 100644 --- a/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt +++ b/features/startchat/impl/src/main/kotlin/io/element/android/features/startchat/impl/root/StartChatView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -146,7 +145,6 @@ fun StartChatView( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CreateRoomRootViewTopBar( onCloseClick: () -> Unit, diff --git a/features/startchat/impl/src/main/res/values-cs/translations.xml b/features/startchat/impl/src/main/res/values-cs/translations.xml index b89f27fb9e..82b7399c41 100644 --- a/features/startchat/impl/src/main/res/values-cs/translations.xml +++ b/features/startchat/impl/src/main/res/values-cs/translations.xml @@ -5,7 +5,7 @@ "Při pokusu o zahájení chatu došlo k chybě" "Vstoupit do místnosti pomocí adresy" "Neplatná adresa" - "Zadejte…" + "Zadejte adresu…" "Odpovídající místnost nalezena" "Místnost nebyla nalezena" "např. #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 d003475a9f..1547123203 100644 --- a/features/startchat/impl/src/main/res/values-ro/translations.xml +++ b/features/startchat/impl/src/main/res/values-ro/translations.xml @@ -1,12 +1,12 @@ "Cameră nouă" - "Director de camere" - "A apărut o eroare la încercarea începerii conversației" - "Gasiți o cameră după adresă" - "Adresa nu e este validă" + "Catalogul camerelor" + "A apărut o eroare la încercarea de a începe conversația" + "Găsiți o cameră după adresă" + "Adresa nu este validă" "Introduceți adresa…" "S-a găsit o cameră" - "Nu a putut fi găsită nici o cameră" + "Camera nu a fost găsită" "de exemplu #nume-camera:matrix.org" diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt index b00a595747..c5a8f992f1 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/DefaultStartChatEntryPointTest.kt @@ -12,7 +12,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.google.common.truth.Truth.assertThat -import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint +import io.element.android.features.createroom.test.FakeCreateRoomEntryPoint import io.element.android.features.startchat.api.StartChatEntryPoint import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.tests.testutils.lambda.lambdaError diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt index ba1b6a4e69..4b7b289490 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/FakeStartChatNavigator.kt @@ -8,7 +8,6 @@ package io.element.android.features.startchat.impl -import io.element.android.features.startchat.StartChatNavigator import io.element.android.libraries.matrix.api.core.RoomIdOrAlias class FakeStartChatNavigator( diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt index 393f51dd2d..924ca691b7 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/joinbyaddress/JoinBaseRoomByAddressPresenterTest.kt @@ -9,8 +9,8 @@ package io.element.android.features.startchat.impl.joinbyaddress import com.google.common.truth.Truth.assertThat -import io.element.android.features.startchat.StartChatNavigator import io.element.android.features.startchat.impl.FakeStartChatNavigator +import io.element.android.features.startchat.impl.StartChatNavigator import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper diff --git a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt index 39d6f34b21..c656e83858 100644 --- a/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt +++ b/features/startchat/impl/src/test/kotlin/io/element/android/features/startchat/impl/root/StartChatPresenterTest.kt @@ -10,12 +10,12 @@ package io.element.android.features.startchat.impl.root import androidx.compose.runtime.MutableState import com.google.common.truth.Truth.assertThat -import io.element.android.features.invitepeople.test.FakeStartDMAction import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction import io.element.android.features.startchat.impl.userlist.FakeUserListPresenter import io.element.android.features.startchat.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.startchat.impl.userlist.UserListDataStore +import io.element.android.features.startchat.test.FakeStartDMAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId diff --git a/features/startchat/test/build.gradle.kts b/features/startchat/test/build.gradle.kts index d6d1471832..ed47f27fc8 100644 --- a/features/startchat/test/build.gradle.kts +++ b/features/startchat/test/build.gradle.kts @@ -11,13 +11,11 @@ plugins { } android { - namespace = "io.element.android.features.invitepeople.test" + namespace = "io.element.android.features.startchat.test" } dependencies { - implementation(libs.coroutines.core) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrix.test) implementation(projects.libraries.architecture) implementation(projects.tests.testutils) api(projects.features.startchat.api) diff --git a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt b/features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt similarity index 95% rename from features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt rename to features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt index c4969df4d9..70cbe1883c 100644 --- a/features/startchat/test/src/main/kotlin/io/element/android/features/invitepeople/test/FakeStartDMAction.kt +++ b/features/startchat/test/src/main/kotlin/io/element/android/features/startchat/test/FakeStartDMAction.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.invitepeople.test +package io.element.android.features.startchat.test import androidx.compose.runtime.MutableState import io.element.android.features.startchat.api.StartDMAction diff --git a/features/userprofile/impl/build.gradle.kts b/features/userprofile/impl/build.gradle.kts index 3e68fb2b9c..b04ca61a23 100644 --- a/features/userprofile/impl/build.gradle.kts +++ b/features/userprofile/impl/build.gradle.kts @@ -31,10 +31,8 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.verifysession.api) @@ -47,7 +45,6 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.mediaviewer.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.features.call.test) testImplementation(projects.features.verifysession.test) testImplementation(projects.features.startchat.test) diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 57a7ae273f..b163b04ce3 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -15,9 +15,9 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeSessionEnterpriseService -import io.element.android.features.invitepeople.test.FakeStartDMAction import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser import io.element.android.features.startchat.api.StartDMAction +import io.element.android.features.startchat.test.FakeStartDMAction import io.element.android.features.userprofile.api.UserProfileEvent import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.api.UserProfileVerificationState diff --git a/features/userprofile/shared/build.gradle.kts b/features/userprofile/shared/build.gradle.kts index 7f4d96c883..1fdce484dd 100644 --- a/features/userprofile/shared/build.gradle.kts +++ b/features/userprofile/shared/build.gradle.kts @@ -30,16 +30,10 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.androidutils) - implementation(projects.libraries.mediaviewer.api) - implementation(projects.libraries.featureflag.api) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.testtags) api(projects.features.userprofile.api) - api(projects.services.apperror.api) implementation(libs.coil.compose) implementation(projects.features.startchat.api) - implementation(projects.services.analytics.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) 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 643db456b1..d2d27638b8 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -47,7 +46,6 @@ import io.element.android.libraries.matrix.api.notification.CallIntent import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun UserProfileView( state: UserProfileState, diff --git a/features/userprofile/shared/src/main/res/values-ro/translations.xml b/features/userprofile/shared/src/main/res/values-ro/translations.xml index 38c544de63..2da1fb49ae 100644 --- a/features/userprofile/shared/src/main/res/values-ro/translations.xml +++ b/features/userprofile/shared/src/main/res/values-ro/translations.xml @@ -1,19 +1,19 @@ "Blocați" - "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." + "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Îi puteți debloca oricând." "Blocați utilizatorul" "Deblocați" - "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "După deblocare, veți putea vedea din nou toate mesajele de la aceștia." "Deblocați utilizatorul" "Blocați" - "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." + "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Îi puteți debloca oricând." "Blocați utilizatorul" "Profil" "Deblocați" - "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." + "După deblocare, veți putea vedea din nou toate mesajele de la aceștia." "Deblocați utilizatorul" "Utilizați aplicația web pentru a verifica acest utilizator." - "Verificare %1$s" - "A apărut o eroare la încercarea începerii conversației" + "Verificați %1$s" + "A apărut o eroare la încercarea de a începe conversația" diff --git a/features/verifysession/impl/build.gradle.kts b/features/verifysession/impl/build.gradle.kts index 89a87edf19..24ba7ccb3e 100644 --- a/features/verifysession/impl/build.gradle.kts +++ b/features/verifysession/impl/build.gradle.kts @@ -25,7 +25,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) @@ -33,15 +32,13 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) - implementation(projects.libraries.preferences.api) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) - implementation(projects.features.logout.api) api(libs.statemachine) api(projects.features.verifysession.api) testCommonDependencies(libs, true) - testImplementation(projects.features.logout.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt index 4cde3f927f..98a1791774 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenter.kt @@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -26,13 +27,18 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter import io.element.android.libraries.dateformatter.api.DateFormatterMode import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -45,6 +51,8 @@ class IncomingVerificationPresenter( @Assisted private val navigator: IncomingVerificationNavigator, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val sessionVerificationService: SessionVerificationService, + private val sessionId: SessionId, + private val sessionStore: SessionStore, private val stateMachine: IncomingVerificationStateMachine, private val dateFormatter: DateFormatter, ) : Presenter { @@ -92,9 +100,15 @@ class IncomingVerificationPresenter( mode = DateFormatterMode.TimeOrDate, ) } + val currentUser by remember { + sessionStore.sessionsFlow().mapNotNull { sessions -> + sessions.find { it.userId == sessionId.value }?.toMatrixUser() + } + }.collectAsState(MatrixUser(sessionId)) val step by remember { derivedStateOf { stateAndDispatch.state.value.toVerificationStep( + currentUser = currentUser, sessionVerificationRequestDetails = verificationRequest.details, formattedSignInTime = formattedSignInTime, ) @@ -147,6 +161,7 @@ class IncomingVerificationPresenter( } private fun StateMachineState?.toVerificationStep( + currentUser: MatrixUser, sessionVerificationRequestDetails: SessionVerificationRequestDetails, formattedSignInTime: String, ): Step = @@ -156,6 +171,7 @@ class IncomingVerificationPresenter( StateMachineState.RejectingIncomingVerification, null -> { Step.Initial( + currentUser = currentUser, deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName, deviceId = sessionVerificationRequestDetails.deviceId, formattedSignInTime = formattedSignInTime, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt index e3663800b2..bd4e343c49 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationState.kt @@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming import androidx.compose.runtime.Stable import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationData import io.element.android.libraries.matrix.api.verification.VerificationRequest @@ -21,6 +22,7 @@ data class IncomingVerificationState( @Stable sealed interface Step { data class Initial( + val currentUser: MatrixUser, val deviceDisplayName: String?, val deviceId: DeviceId, val formattedSignInTime: String, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt index 68672be025..bdf3ad2196 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationStatePreviewParam.kt @@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE +import io.element.android.libraries.designsystem.preview.USER_NAME_BOB import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.FlowId import io.element.android.libraries.matrix.api.core.UserId @@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(24.dp), - ) { - SessionDetailsView( - deviceName = stepInitial.deviceDisplayName, - deviceId = stepInitial.deviceId, - signInFormattedTimestamp = stepInitial.formattedSignInTime, + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + when (request) { + is VerificationRequest.Incoming.OtherSession -> { + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = stepInitial.currentUser, + mode = AccountInfoCardMode.DeviceVerification( + deviceName = stepInitial.deviceDisplayName, + deviceId = stepInitial.deviceId, + signInFormattedTimestamp = stepInitial.formattedSignInTime, + ), + ), ) Text( modifier = Modifier @@ -189,15 +194,15 @@ private fun ContentInitial( textAlign = TextAlign.Center, ) } - } - is VerificationRequest.Incoming.User -> { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { - VerificationUserProfileContent( - user = request.details.senderProfile, + is VerificationRequest.Incoming.User -> { + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = stepInitial.currentUser, + mode = AccountInfoCardMode.UserVerification( + otherUser = request.details.senderProfile, + hint = stringResource(R.string.screen_session_verification_user_requesting_verification), + ), + ), ) } } @@ -219,7 +224,7 @@ private fun IncomingVerificationBottomMenu( showProgress = step.isWaiting, onClick = { eventSink(IncomingVerificationViewEvent.StartVerification) }, ) - TextButton( + OutlinedButton( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_ignore), enabled = !step.isWaiting, @@ -264,9 +269,9 @@ private fun IncomingVerificationBottomMenu( @PreviewsDayNight @Composable -internal fun IncomingVerificationViewPreview(@PreviewParameter( - IncomingVerificationStatePreviewParam::class -) state: IncomingVerificationState) = ElementPreview { +internal fun IncomingVerificationViewPreview( + @PreviewParameter(IncomingVerificationStatePreviewParam::class) state: IncomingVerificationState +) = ElementPreview { IncomingVerificationView( state = state, ) diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt deleted file mode 100644 index 308410f259..0000000000 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/ui/SessionDetailsView.kt +++ /dev/null @@ -1,103 +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.verifysession.impl.incoming.ui - -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.features.verifysession.impl.R -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom -import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize -import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule -import io.element.android.libraries.designsystem.icons.CompoundDrawables -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -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 - -@Composable -fun SessionDetailsView( - deviceName: String?, - deviceId: DeviceId, - signInFormattedTimestamp: String, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = ElementTheme.colors.borderDisabled, - shape = RoundedCornerShape(8.dp) - ) - .padding(24.dp) - .semantics(mergeDescendants = true) {}, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RoundedIconAtom( - modifier = Modifier, - size = RoundedIconAtomSize.Big, - resourceId = CompoundDrawables.ic_compound_devices - ) - Text( - text = deviceName ?: deviceId.value, - style = ElementTheme.typography.fontBodyMdMedium, - color = ElementTheme.colors.textPrimary, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TextWithLabelMolecule( - label = stringResource(R.string.screen_session_verification_request_details_timestamp), - text = signInFormattedTimestamp, - modifier = Modifier.weight(2f), - ) - TextWithLabelMolecule( - label = stringResource(CommonStrings.common_device_id), - text = deviceId.value, - modifier = Modifier.weight(5f), - spellText = true, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun SessionDetailsViewPreview() = ElementPreview { - Column { - SessionDetailsView( - deviceName = "Element X Android", - deviceId = DeviceId("ILAKNDNASDLK"), - signInFormattedTimestamp = "12:34", - ) - SessionDetailsView( - deviceName = null, - deviceId = DeviceId("ILAKNDNASDLK"), - signInFormattedTimestamp = "12:34", - ) - } -} diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt index c9dc4ef45c..8ed13c7cbe 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/outgoing/OutgoingVerificationView.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -53,7 +52,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationD import io.element.android.libraries.matrix.api.verification.VerificationRequest import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun OutgoingVerificationView( state: OutgoingVerificationState, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt deleted file mode 100644 index c398d72ddd..0000000000 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/ui/VerificationUserProfileContent.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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.features.verifysession.impl.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -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.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.components.avatar.Avatar -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.components.avatar.AvatarType -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.utils.CommonDrawables -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.model.getAvatarData -import io.element.android.libraries.matrix.ui.model.getBestName - -/** - * Ref: https://www.figma.com/design/lMrKOhS8BEb75GXVq7FnNI/ER-96--User-Verification-by-Emoji?node-id=116-52049 - */ -@Composable -fun VerificationUserProfileContent( - user: MatrixUser, - modifier: Modifier = Modifier, -) { - val avatarData = remember(user) { - user.getAvatarData(AvatarSize.UserVerification) - } - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(ElementTheme.colors.bgSubtleSecondary) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Avatar( - avatarData = avatarData, - avatarType = AvatarType.User, - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = user.getBestName(), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) - - if (user.displayName.isNullOrEmpty().not()) { - Text( - text = user.userId.value, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - } - } - } -} - -@PreviewsDayNight -@Composable -internal fun VerificationUserProfileContentPreview() = ElementPreview( - drawableFallbackForImages = CommonDrawables.sample_avatar -) { - VerificationUserProfileContent( - user = MatrixUser( - userId = UserId("@alice:example.com"), - displayName = USER_NAME_ALICE, - avatarUrl = "https://example.com/avatar.png", - ) - ) -} diff --git a/features/verifysession/impl/src/main/res/values-fr/translations.xml b/features/verifysession/impl/src/main/res/values-fr/translations.xml index 3a0ceb83b1..c07dc7fe81 100644 --- a/features/verifysession/impl/src/main/res/values-fr/translations.xml +++ b/features/verifysession/impl/src/main/res/values-fr/translations.xml @@ -38,10 +38,13 @@ "Vérification demandée" "Ils ne correspondent pas" "Ils correspondent" + "Veuillez utiliser un autre appareil ou la clé de récupération." + "La vérification avec cet appareil est actuellement indisponible." "Assurez-vous que l’application est ouverte sur un autre appareil avant de commencer la vérification." "Ouvrez l’application sur un autre appareil vérifié" "Pour plus de sécurité, vérifiez cet utilisateur en comparant des émojis sur vos appareils. Pour ce faire, utilisez un moyen de communication fiable." "Vérifier cet utilisateur ?" + "Utilisateur demandant une vérification:" "Pour plus de sécurité, cet autre utilisateur souhaite vérifier votre identité numérique. Des émojis à comparer vous seront présentés." "Vous devriez voir une alerte sur l’autre appareil. Démarrez la vérification à partir de là dès maintenant." "Démarrer la vérification sur l’autre appareil" diff --git a/features/verifysession/impl/src/main/res/values-hr/translations.xml b/features/verifysession/impl/src/main/res/values-hr/translations.xml index 7b92ada6d6..92c1f6139c 100644 --- a/features/verifysession/impl/src/main/res/values-hr/translations.xml +++ b/features/verifysession/impl/src/main/res/values-hr/translations.xml @@ -44,6 +44,7 @@ "Otvorite aplikaciju na drugom potvrđenom uređaju" "Radi dodatne sigurnosti potvrdite ovog korisnika tako da usporedite skup emotikona na svojim uređajima. Učinite to koristeći se pouzdanim načinom komunikacije." "Želite li potvrditi ovog korisnika?" + "Korisnik koji traži provjeru:" "Radi dodatne sigurnosti drugi korisnik želi potvrditi vaš identitet. Bit će vam prikazan skup emotikona za usporedbu." "Trebali biste vidjeti skočni prozor na drugom uređaju. Sada započnite provjeru odatle." "Započni provjeru na drugom uređaju" diff --git a/features/verifysession/impl/src/main/res/values-hu/translations.xml b/features/verifysession/impl/src/main/res/values-hu/translations.xml index 3eedb6730b..97c5f993be 100644 --- a/features/verifysession/impl/src/main/res/values-hu/translations.xml +++ b/features/verifysession/impl/src/main/res/values-hu/translations.xml @@ -44,6 +44,7 @@ "Nyissa meg az alkalmazást egy másik ellenőrzött eszközön" "A fokozott biztonság érdekében ellenőrizze ezt a felhasználót az eszközökön megjelenő emodzsik összehasonlításával. Ehhez használjon megbízható kommunikációs csatornát." "Ellenőrzi ezt a felhasználót?" + "A felhasználó hitelesítést kér:" "A további biztonság érdekében egy másik felhasználó ellenőrizni szeretné személyazonosságát. Meg fog jelenni egy sor emodzsi, melyeket össze kell majd hasonlítania." "A másik eszközön egy felugró ablaknak kell megjelennie. Kezdje el az ellenőrzést onnan." "Ellenőrzés megkezdése a másik eszközön" 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 71cea2c403..bc326cbc00 100644 --- a/features/verifysession/impl/src/main/res/values-pl/translations.xml +++ b/features/verifysession/impl/src/main/res/values-pl/translations.xml @@ -44,6 +44,7 @@ "Otwórz aplikację na drugim zweryfikowanym urządzeniu" "Dla dodatkowej ochrony, zweryfikuj tego użytkownika, porównując zestaw emoji na obu urządzeniach. Zrób to, korzystając z zaufanej metody komunikacji." "Zweryfikować tego użytkownika?" + "Użytkownik proszący o weryfikację:" "Dla dodatkowej ochrony, inny użytkownik chce zweryfikować Twoją tożsamość. Pojawi się unikalny zestaw emoji do porównania." "Powinno wyskoczyć okno na drugim urządzeniu. Rozpocznij tam weryfikację." "Rozpocznij weryfikację na drugim urządzeniu" diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml index 25f897d954..f68eec3a3d 100644 --- a/features/verifysession/impl/src/main/res/values-ro/translations.xml +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -10,45 +10,48 @@ "Dispozitiv verificat" "Utilizați un alt dispozitiv" "Se așteaptă celălalt dispozitiv…" - "Ceva nu este în regulă. Fie cererea a expirat, fie a fost respinsă." - "Confirmați că emoticoanele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." - "Comparați emoticoanele" - "Confirmați că emoji-urile de mai jos corespund cu cele afișate pe dispozitivul celuilalt utilizator." + "Ceva nu este în regulă. Cererea fie a expirat, fie a fost respinsă." + "Confirmați că emojiurile de mai jos se potrivesc cu cele afișate în cealaltă sesiune." + "Comparați emojiurile" + "Confirmați că emojiurile de mai jos corespund cu cele afișate pe dispozitivul celuilalt utilizator." "Confirmați că numerele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." "Comparați numerele" - "Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar ceilalti utilizatori vă vor vedea ca fiind de încredere." + "Acum puteți citi sau trimite mesaje în siguranță pe celălalt dispozitiv." "Acum puteți avea încredere în identitatea digitală a acestui utilizator atunci când trimiteți sau primiți mesaje." "Dispozitiv verificat" "Introduceți cheia de recuperare" - "Fie cererea a expirat, cererea a fost respinsă, fie a existat o nepotrivire de verificare." - "Demonstrați-vă identitatea pentru a accesa mesaje anterioare criptate." + "Cererea a expirat, a fost respinsă sau a existat o nepotrivire de verificare." + "Demonstrați-vă identitatea pentru a accesa mesajele anterioare criptate." "Deschideți o sesiune existentă" "Reîncercați verificarea" - "Sunt pregătit" + "Sunt gata" "Se așteaptă confirmarea" - "Comparați un set unic de emoji-uri." - "Comparăți emoticoalene asigurându-vă că apar în aceeași ordine." - "Conectat" - "Fie cererea a expirat, cererea a fost respinsă, fie a existat o nepotrivire de verificare." - "Verificarea a eșuat" - "Continuați numai dacă dumneavoastră ați inițiat această verificare." + "Comparați un set unic de emojiuri." + "Comparați emojiurile, asigurându-vă că apar în aceeași ordine." + "Conectat(ă)" + "Cererea a expirat, a fost respinsă sau a existat o nepotrivire de verificare." + "Nu s-a putut verifica" + "Continuați numai dacă dvs. ați inițiat această verificare." "Verificați celălalt dispozitiv pentru a vă păstra mesajele anterioare în siguranță." - "Noua dumneavoastră sesiune este acum verificată. Are acces la mesajele dumneavoastră criptate, iar ceilalti utilizatori vă vor vedea ca fiind de încredere." + "Acum puteți citi sau trimite mesaje în siguranță pe celălalt dispozitiv." "Dispozitiv verificat" - "Verificare cerută" + "Verificare solicitată" "Nu se potrivesc" "Se potrivesc" + "Vă rugăm să folosiți un alt dispozitiv sau cheia de recuperare." + "Verificarea cu acest dispozitiv este momentan indisponibilă." "Asigurați-vă că aplicația este deschisă pe celălalt dispozitiv înainte de a începe verificarea de aici." "Deschideți aplicația pe un alt dispozitiv verificat" - "Pentru securitate suplimentară, verificați acest utilizator comparând un set de emoji-uri pe dispozitivele dvs. Faceți acest lucru utilizând o metodă de comunicare de încredere." + "Pentru securitate suplimentară, verificați acest utilizator comparând un set de emojiuri pe dispozitivele dvs. Faceți acest lucru utilizând o metodă de comunicare de încredere." "Verificați acest utilizator?" - "Pentru o securitate sporită, un alt utilizator dorește să vă verifice identitatea digitală. Vi se va afișa un set de emoji-uri pentru comparație." + "Utilizator care solicită verificarea:" + "Pentru o securitate sporită, un alt utilizator dorește să vă verifice identitatea digitală. Vi se va afișa un set de emojiuri pentru comparație." "Ar trebui să vedeți o fereastră pop-up pe celălalt dispozitiv. Începeți verificarea de acolo acum." "Începeți verificarea pe celălalt dispozitiv" "Începeți verificarea pe celălalt dispozitiv" "Se așteaptă celălalt utilizator" "După acceptare, veți putea continua verificarea." "Acceptați cererea de a începe procesul de verificare în cealaltă sesiune pentru a continua." - "Se așteptă acceptarea cererii" - "Deconectare în curs…" + "Se așteaptă acceptarea cererii" + "Se elimină dispozitivul…" diff --git a/features/verifysession/impl/src/main/res/values-uk/translations.xml b/features/verifysession/impl/src/main/res/values-uk/translations.xml index 803e57fa39..620229e2f6 100644 --- a/features/verifysession/impl/src/main/res/values-uk/translations.xml +++ b/features/verifysession/impl/src/main/res/values-uk/translations.xml @@ -44,6 +44,7 @@ "Відкрийте застосунок на іншому звіреному пристрої" "Для додаткової безпеки звірте цього користувача, порівнявши набір емоджі на ваших пристроях. Зробіть це, використовуючи надійний спосіб спілкування." "Звірити цього користувача?" + "Користувач, який запитує звірку:" "Для додаткової безпеки інший користувач хоче звірити вашу цифрову ідентичність. Вам буде показано набір емодзі для порівняння." "На іншому пристрої має з’явитися спливне вікно. Розпочніть звіряння звідти." "Розпочати звіряння на іншому пристрої" diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml index 5472fba56e..d16e1aefe4 100644 --- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml @@ -38,6 +38,8 @@ "已請求驗證" "不一樣" "一樣" + "請使用其他裝置或復原金鑰。" + "目前無法使用此裝置進行驗證" "從這裡開始驗證之前,請確保您在其他裝置中開啟了應用程式。" "在另外一個已驗證的裝置上開啟應用程式" "為了提昇安全性,請透過比較您裝置上的一組表情符號來驗證此使用者。請透過可信的通訊方式來執行此動作。" diff --git a/features/verifysession/impl/src/main/res/values-zh/translations.xml b/features/verifysession/impl/src/main/res/values-zh/translations.xml index 4b32d53ea6..3ae122cbb7 100644 --- a/features/verifysession/impl/src/main/res/values-zh/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh/translations.xml @@ -44,6 +44,7 @@ "在另一台验证的设备上打开应用" "为提高安全性,请通过比较设备上的一组 Emoji 以验证此用户。通过使用安全的方式比如面对面来实施此步骤。" "验证此用户?" + "用户正在请求验证:" "为提高安全性,另一用户想要验证你的数字身份。你将看到一组 Emoij 供你比较。" "你应该会在另一台设备上看到一个弹出窗口。现在从该处开始验证。" "在另一台设备上开始验证" diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index 4aa672cdeb..5803cc2a95 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" @@ -44,6 +45,7 @@ "Open the app on another verified device" "For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate." "Verify this user?" + "User requesting verification:" "For extra security, another user wants to verify your digital identity. You’ll be shown a set of emojis to compare." "You should see a popup on the other device. Start the verification from there now." "Start verification on the other device" diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt index 45d5c623c4..3a969938a1 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationPresenterTest.kt @@ -18,10 +18,17 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationR import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationRequest +import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_DEVICE_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +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.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -41,6 +48,51 @@ class IncomingVerificationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + @Test + fun `present - the current user is built from the stored session`() = runTest { + assertCurrentUserIsBuiltFromStoredSession(anIncomingSessionVerificationRequest) + } + + @Test + fun `present - incoming user verification - the current user is built from the stored session`() = runTest { + assertCurrentUserIsBuiltFromStoredSession(anIncomingUserVerificationRequest) + } + + private suspend fun TestScope.assertCurrentUserIsBuiltFromStoredSession( + verificationRequest: VerificationRequest.Incoming, + ) { + createPresenter( + verificationRequest = verificationRequest, + service = FakeSessionVerificationService( + acknowledgeVerificationRequestLambda = { }, + resetLambda = { }, + ), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_SESSION_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = "avatarData", + ) + ), + ), + ).test { + val step = awaitItem().step as IncomingVerificationState.Step.Initial + assertThat(step.currentUser).isEqualTo(MatrixUser(A_SESSION_ID)) + val stepWithUser = awaitItem().step as IncomingVerificationState.Step.Initial + assertThat(stepWithUser.currentUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + avatarThumbnail = "avatarData", + ) + ) + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - nominal case - incoming verification successful`() = runTest { val acknowledgeVerificationRequestLambda = lambdaRecorder { _ -> } @@ -59,6 +111,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -128,6 +181,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -193,6 +247,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -228,6 +283,7 @@ class IncomingVerificationPresenterTest { val initialState = awaitItem() assertThat(initialState.step).isEqualTo( IncomingVerificationState.Step.Initial( + currentUser = MatrixUser(A_SESSION_ID), deviceDisplayName = "a device name", deviceId = A_DEVICE_ID, formattedSignInTime = "567 TimeOrDate false", @@ -310,15 +366,32 @@ private val anIncomingSessionVerificationRequest = VerificationRequest.Incoming. ) ) +private val anIncomingUserVerificationRequest = VerificationRequest.Incoming.User( + details = SessionVerificationRequestDetails( + senderProfile = MatrixUser( + userId = A_USER_ID_2, + displayName = "another user name", + avatarUrl = null, + ), + flowId = FlowId("flowId"), + deviceId = A_DEVICE_ID, + deviceDisplayName = "a device name", + firstSeenTimestamp = A_TIMESTAMP, + ) +) + internal fun TestScope.createPresenter( verificationRequest: VerificationRequest.Incoming = anIncomingSessionVerificationRequest, navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() }, service: SessionVerificationService = FakeSessionVerificationService(), dateFormatter: DateFormatter = FakeDateFormatter(), + sessionStore: SessionStore = InMemorySessionStore(), ) = IncomingVerificationPresenter( verificationRequest = verificationRequest, navigator = navigator, sessionVerificationService = service, + sessionId = A_SESSION_ID, + sessionStore = sessionStore, stateMachine = IncomingVerificationStateMachine(service), dateFormatter = dateFormatter, sessionCoroutineScope = backgroundScope, diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt index ae3e6932ac..bcbacf00fa 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt @@ -15,10 +15,16 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.impl.file.ColorationMode import io.element.android.features.viewfolder.impl.file.FileContent +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @ContributesBinding(AppScope::class) -class DefaultTextFileViewer : TextFileViewer { +class DefaultTextFileViewer( + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, +) : TextFileViewer { @Composable override fun Render( lines: ImmutableList, @@ -27,6 +33,11 @@ class DefaultTextFileViewer : TextFileViewer { FileContent( lines = lines, colorationMode = ColorationMode.None, + onLineClick = { line -> + clipboardHelper.copyPlainText(line) { + toastHelper.show(CommonStrings.common_line_copied_to_clipboard) + } + }, modifier = modifier ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt index c68d630869..a0afd75a28 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt @@ -23,12 +23,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -37,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList internal fun FileContent( lines: ImmutableList, colorationMode: ColorationMode, + onLineClick: (String) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -60,6 +59,7 @@ internal fun FileContent( lineNumber = index + 1, line = line, colorationMode = colorationMode, + onClick = { onLineClick(line) }, ) } } @@ -71,18 +71,12 @@ private fun LineRow( lineNumber: Int, line: String, colorationMode: ColorationMode, + onClick: () -> Unit, ) { - val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard) Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = { - context.copyToClipboard( - text = line, - toastMessage = toastMessage, - ) - }) + .clickable(onClick = onClick) ) { Text( modifier = Modifier diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt index 2ff6faf4ef..7ba5338fdf 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt @@ -11,4 +11,5 @@ package io.element.android.features.viewfolder.impl.file sealed interface ViewFileEvent { data object SaveOnDisk : ViewFileEvent data object Share : ViewFileEvent + data class CopyToClipboard(val text: String) : ViewFileEvent } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt index 571f3eb053..0ffea1dc08 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt @@ -18,8 +18,11 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -30,6 +33,8 @@ class ViewFilePresenter( private val fileContentReader: FileContentReader, private val fileShare: FileShare, private val fileSave: FileSave, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory interface Factory { @@ -48,6 +53,9 @@ class ViewFilePresenter( when (event) { ViewFileEvent.Share -> coroutineScope.share(path) ViewFileEvent.SaveOnDisk -> coroutineScope.save(path) + is ViewFileEvent.CopyToClipboard -> clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_line_copied_to_clipboard) + } } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt index 3c4ce9674c..ee35614a52 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -11,7 +11,6 @@ package io.element.android.features.viewfolder.impl.file import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -30,7 +29,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewFileView( state: ViewFileState, @@ -82,6 +80,7 @@ fun ViewFileView( modifier = Modifier.weight(1f), lines = state.lines.data.toImmutableList(), colorationMode = state.colorationMode, + onLineClick = { state.eventSink(ViewFileEvent.CopyToClipboard(it)) }, ) is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null) } 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 89fb3da7f4..0817ca7234 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.SubdirectoryArrowLeft -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -39,7 +38,6 @@ 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewFolderView( state: ViewFolderState, diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt similarity index 80% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt index 6dd621ac6a..9e5b158b5d 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileContentReader.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileContentReader.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileContentReader +package io.element.android.features.viewfolder.impl.file class FakeFileContentReader : FileContentReader { private var result: Result> = Result.success(emptyList()) diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt similarity index 76% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt index 3649d30759..87af5947bb 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileSave.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileSave.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileSave +package io.element.android.features.viewfolder.impl.file class FakeFileSave : FileSave { var hasBeenCalled = false diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt similarity index 76% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt index 7348e4bed0..3b3a4a7245 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/FakeFileShare.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/FakeFileShare.kt @@ -6,9 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file - -import io.element.android.features.viewfolder.impl.file.FileShare +package io.element.android.features.viewfolder.impl.file class FakeFileShare : FileShare { var hasBeenCalled = false diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt similarity index 71% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt index c3be8d84b9..e61dfebad2 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenterTest.kt @@ -6,20 +6,19 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.file +package io.element.android.features.viewfolder.impl.file 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.viewfolder.impl.file.ColorationMode -import io.element.android.features.viewfolder.impl.file.FileContentReader -import io.element.android.features.viewfolder.impl.file.FileSave -import io.element.android.features.viewfolder.impl.file.FileShare -import io.element.android.features.viewfolder.impl.file.ViewFileEvent -import io.element.android.features.viewfolder.impl.file.ViewFilePresenter +import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper +import io.element.android.libraries.androidutils.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -126,17 +125,51 @@ class ViewFilePresenterTest { } } + @Test + fun `present - copy line on old device shows a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = true) + val toastHelper = FakeToastHelper() + val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine") + assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_line_copied_to_clipboard) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - copy line on new device does not show a toast`() = runTest { + val clipboardHelper = FakeClipboardHelper(isOldDevice = false) + val toastHelper = FakeToastHelper() + val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine")) + assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine") + assertThat(toastHelper.shownToasts).isEmpty() + cancelAndIgnoreRemainingEvents() + } + } + private fun createPresenter( path: String = "aPath", name: String = "aName", fileContentReader: FileContentReader = FakeFileContentReader(), fileShare: FileShare = FakeFileShare(), fileSave: FileSave = FakeFileSave(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ) = ViewFilePresenter( path = path, name = name, fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt similarity index 80% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt index 7761bfb976..fd28fc8040 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/FakeFolderExplorer.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/FakeFolderExplorer.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.folder +package io.element.android.features.viewfolder.impl.folder -import io.element.android.features.viewfolder.impl.folder.FolderExplorer import io.element.android.features.viewfolder.impl.model.Item class FakeFolderExplorer : FolderExplorer { diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt similarity index 94% rename from features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt rename to features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt index 31fd91eabe..1e8f90976f 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/folder/ViewFolderPresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderPresenterTest.kt @@ -6,14 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.viewfolder.test.folder +package io.element.android.features.viewfolder.impl.folder 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.viewfolder.impl.folder.FolderExplorer -import io.element.android.features.viewfolder.impl.folder.ViewFolderPresenter import io.element.android.features.viewfolder.impl.model.Item import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta diff --git a/gradle.properties b/gradle.properties index c263d7b266..27f8a43117 100644 --- a/gradle.properties +++ b/gradle.properties @@ -39,6 +39,7 @@ org.gradle.configuration-cache.parallel=true kotlin.incremental=true # Dummy values for signing secrets / nightly +signing.element.nightly.storeFile= signing.element.nightly.storePassword=Secret signing.element.nightly.keyId=Secret signing.element.nightly.keyPassword=Secret @@ -64,3 +65,9 @@ android.r8.gradual.support=true # Enabled parallel sync for Gradle 9.4+ org.gradle.tooling.parallel=true + +# When re-recording, keep the existing screenshot if it differs by less than `maxPercentDifference` +# (see PaparazziPreviewRule in tests/uitests/.../base/ScreenshotTest.kt). Without this, recording has +# no tolerance at all while verification has one, so renderer noise far below the threshold that +# `verifyPaparazziDebug` accepts still rewrites the Git LFS blob of every affected screenshot. +app.cash.paparazzi.overwriteOnMaxPercentDifference=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ede6519a9..bcb3d50090 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,21 +5,20 @@ # Project android_gradle_plugin = "9.3.2" # When updating this, please also update the version in the file ./idea/kotlinc.xml -kotlin = "2.4.10" +kotlin = "2.4.20" kotlinpoet = "2.4.0" -ksp = "2.3.11" +ksp = "2.3.12" firebaseAppDistribution = "5.3.0" # AndroidX core = "1.18.0" datastore = "1.2.1" -constraintlayout = "2.2.2" constraintlayout_compose = "1.1.2" lifecycle = "2.11.0" activity = "1.13.0" media3 = "1.11.1" camera = "1.6.2" -work = "2.11.2" +work = "2.12.0" # Compose compose_bom = "2026.09.00" @@ -38,21 +37,20 @@ datetime = "0.8.0" serialization_json = "1.11.0" #other -coil = "3.6.2" +coil = "3.6.3" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" -sqldelight = "2.3.2" +sqldelight = "2.4.1" wysiwyg = "2.42.1" telephoto = "0.19.0" -haze = "1.7.2" -maplibre-compose = "0.15.0" +maplibre-compose = "0.19.0" # Dependency analysis dependencyAnalysis = "3.19.2" # DI -metro = "1.4.3" +metro = "1.4.5" # Auto service autoservice = "1.1.1" @@ -64,7 +62,9 @@ detekt_compose_rules = "0.4.28" # See https://github.com/pinterest/ktlint/releases/ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" -kover = "0.9.9" +kover = "0.9.11" +# The native MatrixRTC call component, https://github.com/element-hq/element-call-android +element_call = "0.1.0-rc.8" [libraries] # Project @@ -82,7 +82,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.18.0" +google_firebase_bom = "com.google.firebase:firebase-bom:35.0.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" } @@ -92,16 +92,15 @@ jna = "net.java.dev.jna:jna:5.19.1" # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } -androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.10.0" +androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.11.0" androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.4.2" -androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" } androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } -androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.1.0" +androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.1.1" androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0" @@ -121,10 +120,10 @@ androidx_activity_activity = { module = "androidx.activity:activity", version.re androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } androidx_startup = "androidx.startup:startup-runtime:1.2.0" androidx_preference = "androidx.preference:preference:1.2.1" -androidx_webkit = "androidx.webkit:webkit:1.17.0" +androidx_webkit = "androidx.webkit:webkit:1.17.1" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } -androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha23' } +androidx_compose_material3 = { module = "androidx.compose.material3:material3", version = '1.5.0-alpha29' } 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" } @@ -170,8 +169,8 @@ test_mockk = "io.mockk:mockk:1.14.11" test_konsist = "com.github.jmartinesp:konsist:0.18.0" test_turbine = "app.cash.turbine:turbine:1.2.1" test_truth = "com.google.truth:truth:1.4.5" -test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.22" -test_robolectric = "org.robolectric:robolectric:4.16.1" +test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.24" +test_robolectric = "org.robolectric:robolectric:4.17" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.9.3" test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } @@ -183,7 +182,12 @@ 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.09.26" +# `strictly` because element-call-matrix is a published AAR compiled against one SDK version and declares +# it as a plain `require`: without this, a library built against a newer SDK would silently drag this app +# onto it. With it, this pin always wins, and a library that needs something newer fails at configuration +# time naming both versions instead of at runtime with NoSuchMethodError. +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.15" } } +rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -204,7 +208,7 @@ matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } -sqlcipher = "net.zetetic:sqlcipher-android:4.19.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.19.1" sqlite = "androidx.sqlite:sqlite-ktx:2.7.1" unifiedpush = "org.unifiedpush.android:connector:3.3.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" @@ -217,17 +221,15 @@ maplibre_compose_location_runtime_gms = { module = "org.maplibre.compose:locatio opusencoder = "io.element.android:opusencoder:1.2.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.7.1-android" +google_guava = "com.google.guava:guava:33.7.2-android" -haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } -haze_materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.60.7" -sentry = "io.sentry:sentry-android:8.55.0" +sentry = "io.sentry:sentry-android:8.59.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" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.38.0" # Emojibase matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.5.3" @@ -237,7 +239,14 @@ 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.26.0" +element_call_embedded = "io.element.android:element-call-embedded:0.28.0" +# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together. +element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" } +element_call_api = { module = "io.element.android:element-call-api" } +element_call_impl = { module = "io.element.android:element-call" } +element_call_ui = { module = "io.element.android:element-call-ui" } +element_call_matrix = { module = "io.element.android:element-call-matrix" } +element_call_test = { module = "io.element.android:element-call-test" } # Auto services google_autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" } @@ -262,7 +271,7 @@ ktlint = "org.jlleitschuh.gradle.ktlint:14.2.0" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencycheck = "org.owasp.dependencycheck:13.0.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } -paparazzi = "app.cash.paparazzi:2.0.0-alpha05" +paparazzi = "app.cash.paparazzi:2.0.0-alpha05.1" sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } sonarqube = "org.sonarqube:7.5.0.8588" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef..9fa7f28828 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 760c45969c..a78c88e539 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=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a -distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 8508ef684d..03754c4c3d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/libraries/accountselect/impl/build.gradle.kts b/libraries/accountselect/impl/build.gradle.kts index 2497299f5b..e28b13bc44 100644 --- a/libraries/accountselect/impl/build.gradle.kts +++ b/libraries/accountselect/impl/build.gradle.kts @@ -20,8 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.core) - implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index be6a8bea6f..8ed9a18b85 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -13,8 +13,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -29,13 +29,7 @@ class AccountSelectPresenter( val accounts by produceState>(persistentListOf()) { // Do not use sessionStore.sessionsFlow() to not make it change when an account is selected. value = sessionStore.getAllSessions() - .map { - MatrixUser( - userId = UserId(it.userId), - displayName = it.userDisplayName, - avatarUrl = it.userAvatarUrl, - ) - } + .map { it.toMatrixUser() } .toImmutableList() } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt index 74f15f8cfa..0df2bde377 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectView.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -33,7 +32,6 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.ui.strings.CommonStrings @Suppress("MultipleEmitters") // False positive -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountSelectView( state: AccountSelectState, diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index 8b0f581f6d..f9b9aeebcd 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -44,6 +44,7 @@ class AccountSelectPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -66,6 +67,7 @@ class AccountSelectPresenterTest { userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", ) ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt index ce6f61f280..c452902404 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt @@ -9,8 +9,11 @@ package io.element.android.libraries.androidutils.clipboard import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context +import android.os.Build +import android.os.PersistableBundle import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -24,7 +27,30 @@ class AndroidClipboardHelper( ) : ClipboardHelper { private val clipboardManager = requireNotNull(context.getSystemService()) - override fun copyPlainText(text: String) { - clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) + override fun copyPlainText( + text: String, + isSensitive: Boolean, + onSuccessOnOldDevice: () -> Unit, + ) { + val clipData = ClipData.newPlainText("", text) + if (isSensitive) { + clipData.description.extras = PersistableBundle().apply { + putBoolean(EXTRA_IS_SENSITIVE, true) + } + } + clipboardManager.setPrimaryClip(clipData) + // On Android 13+, the system already displays a confirmation when content is copied to the clipboard. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + onSuccessOnOldDevice() + } + } + + private companion object { + // The constant only exists from API 33, but the documentation recommends using its value on older versions too. + val EXTRA_IS_SENSITIVE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt index 1d0ab8e3c4..0add39fe76 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt @@ -8,9 +8,18 @@ package io.element.android.libraries.androidutils.clipboard -/** - * Wrapper class for handling clipboard operations so it can be used in JVM environments. - */ interface ClipboardHelper { - fun copyPlainText(text: String) + /** + * Copy a text to the clipboard. + * + * @param text the text to copy + * @param isSensitive if true, the clip is flagged so the system clipboard preview (Android 13+) and keyboards hide its content. + * @param onSuccessOnOldDevice invoked after the copy only on Android versions older than 13, since newer versions already + * display a confirmation. Can be used to render a Toast or a Snackbar. + */ + fun copyPlainText( + text: String, + isSensitive: Boolean = false, + onSuccessOnOldDevice: () -> Unit, + ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt index 9f91b3a4ce..a46053662e 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt @@ -8,10 +8,21 @@ package io.element.android.libraries.androidutils.clipboard -class FakeClipboardHelper : ClipboardHelper { +class FakeClipboardHelper( + private val isOldDevice: Boolean = true, +) : ClipboardHelper { var clipboardContents: Any? = null + var isSensitive: Boolean? = null - override fun copyPlainText(text: String) { + override fun copyPlainText( + text: String, + isSensitive: Boolean, + onSuccessOnOldDevice: () -> Unit, + ) { clipboardContents = text + this.isSensitive = isSensitive + if (isOldDevice) { + onSuccessOnOldDevice() + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 7c5f0450e9..9023a8f935 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long { Timber.v("Get size of ${it.absolutePath}") true } - .sumOf { it.length() } + // Only sum the actual size of files, not directories, to avoid double counting or worse, + // since the docs mention that `length()` for directories is not reliable. + // For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder, + // which is a common block size on many file systems. + .sumOf { if (it.isFile) it.length() else 4096L } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt deleted file mode 100644 index 53a1c33995..0000000000 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2022-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.androidutils.system - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.core.content.getSystemService - -class CopyToClipboardUseCase( - private val context: Context, -) { - fun execute(text: CharSequence) { - context.getSystemService() - ?.setPrimaryClip(ClipData.newPlainText("", text)) - } -} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 861aca032d..234697226f 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -50,25 +50,6 @@ fun Context.getVersionCodeFromManifest(): Long { ) } -// ============================================================================================================== -// Clipboard helper -// ============================================================================================================== - -/** - * Copy a text to the clipboard, and display a Toast when done. - * - * @receiver the context - * @param text the text to copy - * @param toastMessage content of the toast message as a String resource. Null for no toast - */ -fun Context.copyToClipboard( - text: CharSequence, - toastMessage: String? = null -) { - CopyToClipboardUseCase(this).execute(text) - toastMessage?.let { toast(it) } -} - /** * Shows notification settings for the current app. * In android O will directly opens the notification settings, in lower version it will show the App settings diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt new file mode 100644 index 0000000000..d2ba027f19 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.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.libraries.androidutils.toast + +import android.content.Context +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.toast +import io.element.android.libraries.di.annotations.ApplicationContext + +@ContributesBinding(AppScope::class) +class AndroidToastHelper( + @ApplicationContext private val context: Context, +) : ToastHelper { + override fun show(resId: Int) { + context.toast(resId) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt new file mode 100644 index 0000000000..a4b26d1343 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt @@ -0,0 +1,16 @@ +/* + * 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.androidutils.toast + +class FakeToastHelper : ToastHelper { + val shownToasts = mutableListOf() + + override fun show(resId: Int) { + shownToasts.add(resId) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt new file mode 100644 index 0000000000..e48022a455 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt @@ -0,0 +1,14 @@ +/* + * 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.androidutils.toast + +import androidx.annotation.StringRes + +interface ToastHelper { + fun show(@StringRes resId: Int) +} diff --git a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt similarity index 94% rename from libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt rename to libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt index ecb263dbb3..a75a71fc57 100644 --- a/libraries/audio/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeAudioFocus.kt +++ b/libraries/audio/test/src/main/kotlin/io/element/android/libraries/audio/test/FakeAudioFocus.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.mediaplayer.test +package io.element.android.libraries.audio.test import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester diff --git a/libraries/cachestore/impl/build.gradle.kts b/libraries/cachestore/impl/build.gradle.kts index f0c7ba237c..f357cf8446 100644 --- a/libraries/cachestore/impl/build.gradle.kts +++ b/libraries/cachestore/impl/build.gradle.kts @@ -19,8 +19,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.cachestore.api) implementation(libs.sqldelight.driver.android) diff --git a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt similarity index 93% rename from libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt rename to libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt index 36d7e05532..d3581f9686 100644 --- a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseCacheStoreTest.kt +++ b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/DatabaseCacheStoreTest.kt @@ -5,13 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.impl +package io.element.android.libraries.cachestore.impl import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import com.google.common.truth.Truth.assertThat import io.element.android.libraries.cachestore.api.CacheData -import io.element.android.libraries.cachestore.impl.CacheDatabase -import io.element.android.libraries.cachestore.impl.DatabaseCacheStore import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before diff --git a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt similarity index 89% rename from libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt rename to libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt index 3dca9efaf3..149f21ecfb 100644 --- a/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/cachestore/impl/src/test/kotlin/io/element/android/libraries/cachestore/impl/Fixtures.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.impl +package io.element.android.libraries.cachestore.impl import io.element.android.libraries.cachestore.CacheData import java.util.Date diff --git a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt similarity index 87% rename from libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt rename to libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt index 30633e8ff9..b7800bdf86 100644 --- a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/CacheData.kt +++ b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/CacheData.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.test +package io.element.android.libraries.cachestore.test import io.element.android.libraries.cachestore.api.CacheData diff --git a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt similarity index 93% rename from libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt rename to libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt index f029c8bcde..132063541e 100644 --- a/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemoryCacheStore.kt +++ b/libraries/cachestore/test/src/main/kotlin/io/element/android/libraries/cachestore/test/InMemoryCacheStore.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.sessionstorage.test +package io.element.android.libraries.cachestore.test import io.element.android.libraries.cachestore.api.CacheData import io.element.android.libraries.cachestore.api.CacheStore diff --git a/libraries/cryptography/impl/build.gradle.kts b/libraries/cryptography/impl/build.gradle.kts index 3a1f55126e..b347ce1cbc 100644 --- a/libraries/cryptography/impl/build.gradle.kts +++ b/libraries/cryptography/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.di) implementation(libs.coroutines.core) api(projects.libraries.cryptography.api) diff --git a/libraries/dateformatter/impl/build.gradle.kts b/libraries/dateformatter/impl/build.gradle.kts index c9ddf459d6..84f8a0aea6 100644 --- a/libraries/dateformatter/impl/build.gradle.kts +++ b/libraries/dateformatter/impl/build.gradle.kts @@ -37,13 +37,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.designsystem) implementation(projects.libraries.di) - implementation(projects.libraries.uiStrings) implementation(projects.services.toolbox.api) api(projects.libraries.dateformatter.api) api(libs.datetime) testCommonDependencies(libs, true) - testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.services.toolbox.test) } diff --git a/libraries/dateformatter/test/build.gradle.kts b/libraries/dateformatter/test/build.gradle.kts index 0367030ae1..fa0861c1a2 100644 --- a/libraries/dateformatter/test/build.gradle.kts +++ b/libraries/dateformatter/test/build.gradle.kts @@ -16,5 +16,4 @@ android { dependencies { api(projects.libraries.dateformatter.api) - api(libs.datetime) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt index 3bbd3c12a7..178b75b777 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt @@ -11,36 +11,46 @@ package io.element.android.libraries.designsystem.atomic.atoms import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.SemanticColors import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.Text private const val MAX_COUNT = 99 -private const val MAX_COUNT_STRING = "+$MAX_COUNT" +private const val MAX_COUNT_STRING = "$MAX_COUNT+" /** * A counter atom that displays a number in a circle. * Figma link : https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2805-2649&m=dev * * @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING]. - * If the number is less than 1, the counter will not be displayed. + * If the number is less than 1, just a dot will be displayed instead. * @param modifier The modifier to apply to this layout. * @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default. - * @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary]. + * @param contentColor The text color inside the counter. When null, uses [SemanticColors.textOnSolidPrimary]. + * @param contentPadding The padding to apply to the text inside the counter. * @param textStyle The style to apply to the text inside the counter. + * @param dotSize The size of the dot when [count] is less than 1. It defaults to `12.0.dp`. * @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme. * Only used when [containerColor] is null. */ @@ -50,40 +60,60 @@ fun CounterAtom( modifier: Modifier = Modifier, containerColor: Color? = null, contentColor: Color? = null, + contentPadding: PaddingValues = PaddingValues(vertical = 0.5.dp), textStyle: TextStyle = CounterAtomDefaults.textStyle, + dotSize: Dp = 12.0.dp, isCritical: Boolean = false, ) { - if (count < 1) return val countAsText = when (count) { - in 0..MAX_COUNT -> count.toString() + in Int.MIN_VALUE..0 -> null + in 1..MAX_COUNT -> count.toString() else -> MAX_COUNT_STRING } + + val density = LocalDensity.current val textMeasurer = rememberTextMeasurer() - // Measure the maximum count string size - val textLayoutResult = textMeasurer.measure( - text = MAX_COUNT_STRING, - style = textStyle - ) - val textSize = textLayoutResult.size - val squareSize = maxOf(textSize.width, textSize.height) + val minTextSize = remember(density.density, textStyle) { + val result = textMeasurer.measure( + text = MAX_COUNT_STRING, + style = textStyle + ) + with(density) { + minOf(result.size.width, result.size.height).toDp() + } + } + val sizeModifier = if (countAsText?.length == 1) { + Modifier.size(minTextSize) + } else { + Modifier.sizeIn(minHeight = minTextSize) + } + + val extraPadding = if (countAsText?.length == 1) { + contentPadding + } else { + contentPadding + PaddingValues(horizontal = 4.dp) + } + val backgroundColor = containerColor ?: if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconAccentPrimary + Box( modifier = modifier - .size(squareSize.toDp() + 1.dp) - .clip(CircleShape) - .background( - containerColor ?: if (isCritical) { - ElementTheme.colors.iconCriticalPrimary - } else { - ElementTheme.colors.iconAccentPrimary - } + .clip(RoundedCornerShape(100)) + .background(backgroundColor) + .then( + if (countAsText == null) Modifier.size(dotSize) else Modifier ) ) { - Text( - modifier = Modifier.align(Alignment.Center), - text = countAsText, - style = textStyle, - color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, - ) + countAsText?.let { + Text( + modifier = sizeModifier + .align(Alignment.Center) + .padding(extraPadding), + text = countAsText, + style = textStyle, + color = contentColor ?: ElementTheme.colors.textOnSolidPrimary, + textAlign = TextAlign.Center, + ) + } } } @@ -95,10 +125,10 @@ object CounterAtomDefaults { @PreviewsDayNight @Composable internal fun CounterAtomPreview() = ElementPreview { - Column(verticalArrangement = spacedBy(2.dp)) { + Row(horizontalArrangement = spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { CounterAtom(count = 0) CounterAtom(count = 4) - CounterAtom(count = 99) + CounterAtom(count = 10) CounterAtom(count = 100) CounterAtom(count = 4, isCritical = true) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt index bdf039edb8..606d57daa5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt @@ -9,15 +9,15 @@ package io.element.android.libraries.designsystem.atomic.atoms import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -26,40 +26,41 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.unreadIndicator +/** + * A small indicator to show the number of unread messages or notifications. + * + * @param count The number of unread messages or notifications. If null, the indicator will be hidden, if 0, a notification dot will be shown. + * @param modifier The modifier to be applied to the indicator. + * @param dotIndicatorSize The size of the dot indicator. + * @param color The background color of the indicator. + * @param contentDescription The content description for accessibility. + * @param border The border to be applied to the indicator. + * @param contentPadding The padding inside the indicator. + */ @Composable fun UnreadIndicatorAtom( + count: Long?, modifier: Modifier = Modifier, - size: Dp = 12.dp, - count: Long? = null, - color: Color = ElementTheme.colors.unreadIndicator, - isVisible: Boolean = true, + dotIndicatorSize: Dp = 12.dp, + color: Color = ElementTheme.colors.iconAccentPrimary, contentDescription: String? = null, border: BorderStroke? = null, + contentPadding: PaddingValues = PaddingValues.Zero, ) { - when { - !isVisible -> Spacer(modifier = modifier.size(size)) - count != null && count >= 1 -> CounterAtom( + count?.let { + CounterAtom( count = count.toInt(), modifier = modifier .semantics { contentDescription?.let { this.contentDescription = it } } - .then(if (border != null) Modifier.border(border, CircleShape) else Modifier), + .then(if (count == 0L) Modifier.size(dotIndicatorSize) else Modifier) + .then(if (border != null) Modifier.border(border, RoundedCornerShape(percent = 100)) else Modifier), containerColor = color, contentColor = ElementTheme.colors.bgCanvasDefault, textStyle = ElementTheme.typography.fontBodySmMedium, - ) - else -> Box( - modifier = modifier - .semantics { - contentDescription?.let { this.contentDescription = it } - } - .size(size) - .clip(CircleShape) - .background(color) - .then(if (border != null) Modifier.border(border, CircleShape) else Modifier), + contentPadding = contentPadding, ) } } @@ -67,5 +68,12 @@ fun UnreadIndicatorAtom( @PreviewsDayNight @Composable internal fun UnreadIndicatorAtomPreview() = ElementPreview { - UnreadIndicatorAtom() + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { + UnreadIndicatorAtom(count = null) + UnreadIndicatorAtom(count = 0) + UnreadIndicatorAtom(count = 1) + UnreadIndicatorAtom(count = 20) + UnreadIndicatorAtom(count = 99) + UnreadIndicatorAtom(count = 999) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt index 56255d7623..a0339b8572 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/IconTitleSubtitleMolecule.kt @@ -32,6 +32,8 @@ import io.element.android.libraries.designsystem.theme.components.Text /** * IconTitleSubtitleMolecule is a molecule which displays an icon, a title and a subtitle. * + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1964-3385 + * * @param title the title to display * @param subTitle the subtitle to display * @param iconStyle the style of the [BigIcon] to display diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt index a29e1b743c..5c28566700 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -40,7 +39,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar * - a content. * - a footer, as ButtonColumnMolecule */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FlowStepPage( iconStyle: BigIcon.Style, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt index cf6329ee66..668c9b571c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/BigIcon.kt @@ -53,13 +53,11 @@ object BigIcon { * @param vectorIcon the [ImageVector] to display * @param contentDescription the content description of the icon, if any. It defaults to `null` * @param useCriticalTint whether the icon and background should be rendered using critical tint - * @param usePrimaryTint whether the icon should be rendered using primary tint */ data class Default( val vectorIcon: ImageVector, val contentDescription: String? = null, val useCriticalTint: Boolean = false, - val usePrimaryTint: Boolean = false, ) : Style /** @@ -129,10 +127,8 @@ object BigIcon { val iconTint = when (style) { is Style.Default -> if (style.useCriticalTint) { ElementTheme.colors.iconCriticalPrimary - } else if (style.usePrimaryTint) { - ElementTheme.colors.iconPrimary } else { - ElementTheme.colors.iconSecondary + ElementTheme.colors.iconPrimary } Style.Alert, Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt index 566c677766..b0a118049b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/TopAppBarScrollBehaviorLayout.kt @@ -8,7 +8,6 @@ package io.element.android.libraries.designsystem.components -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.contentColorFor @@ -24,7 +23,6 @@ import io.element.android.compound.theme.ElementTheme * It places the content according to the current height offset of the scroll behavior. * */ -@ExperimentalMaterial3Api @Composable fun TopAppBarScrollBehaviorLayout( scrollBehavior: TopAppBarScrollBehavior, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index 99c45c67d9..88f8f3abb5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -18,6 +18,8 @@ data class AvatarData( val name: String?, val url: String? = null, val size: AvatarSize, + /** Base64 encoded thumbnail of [url], stored locally, used as a fallback when the avatar cannot be loaded. */ + val thumbnail: String? = null, ) { val initialLetter by lazy { // For roomIds, use "#" as initial diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt index 53ef5593ed..ee069f71fd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -16,9 +16,11 @@ fun anAvatarData( name: String? = USER_NAME_ALICE, url: String? = null, size: AvatarSize = AvatarSize.RoomListItem, + thumbnail: String? = null, ) = AvatarData( id = id, name = name, url = url, size = size, + thumbnail = thumbnail, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index e4c8ebc641..ed1b0b2e6f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -31,6 +31,7 @@ enum class AvatarSize(val dp: Dp) { UserHeader(96.dp), UserListItem(36.dp), + AccountInfoUser(36.dp), SelectedUser(52.dp), SelectedRoom(56.dp), @@ -72,8 +73,6 @@ enum class AvatarSize(val dp: Dp) { DmCreationConfirmation(64.dp), - UserVerification(52.dp), - OrganizationHeader(64.dp), SpaceHeader(64.dp), RoomPreviewHeader(64.dp), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index da57fbcbe1..e8dcd94a77 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape @@ -21,8 +22,10 @@ import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.designsystem.components.avatar.AvatarData import timber.log.Timber +import kotlin.io.encoding.Base64 @Composable internal fun ImageAvatar( @@ -33,6 +36,9 @@ internal fun ImageAvatar( contentDescription: String? = null, ) { val size = forcedAvatarSize ?: avatarData.size.dp + val thumbnailBytes = remember(avatarData.thumbnail) { + avatarData.thumbnail?.let { runCatchingExceptions { Base64.decode(it) }.getOrNull() } + } SubcomposeAsyncImage( model = avatarData, contentDescription = contentDescription, @@ -51,15 +57,17 @@ internal fun ImageAvatar( "Error loading avatar $state\n${state.result}" ) } - InitialLetterAvatar( + ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, ) } - else -> InitialLetterAvatar( + else -> ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, @@ -67,3 +75,44 @@ internal fun ImageAvatar( } } } + +/** + * Render the locally stored thumbnail of the avatar if any, else the initial letter. + */ +@Composable +private fun ThumbnailOrInitialLetterAvatar( + avatarData: AvatarData, + thumbnailBytes: ByteArray?, + avatarShape: Shape, + forcedAvatarSize: Dp?, + contentDescription: String?, +) { + if (thumbnailBytes == null) { + InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } else { + SubcomposeAsyncImage( + model = thumbnailBytes, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(forcedAvatarSize ?: avatarData.size.dp) + .clip(avatarShape) + ) { + val collectedState by painter.state.collectAsState() + when (collectedState) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + else -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt index 3c9204ac3e..c3175f1a84 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/AlertDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AlertDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index 9d217da059..1a0391da83 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConfirmationDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index 3c79dd7714..3e6d271c87 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -23,7 +22,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ErrorDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt index e85725cfc0..8f97df5317 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialogWithDoNotShowAgain.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.SimpleAlertDia import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ErrorDialogWithDoNotShowAgain( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index 91c058a0e4..cf179a31a3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,7 +28,6 @@ import io.element.android.libraries.designsystem.theme.components.ListSupporting import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListDialog( onSubmit: () -> Unit, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt index 6fe899287b..5d5ea3539b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList @@ -32,7 +31,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MultipleSelectionDialog( options: ImmutableList, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt index 0b313e1180..25fcf60cad 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.theme.components.DialogPreview import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RetryDialog( content: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt index 8a64fce510..a5e72c44f3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,7 +28,6 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SingleSelectionDialog( options: ImmutableList, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt index aacc8ccc1c..0afd5e047d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackSync.kt @@ -152,8 +152,10 @@ internal fun remainingPlaybackAnimationMs( durationMs: Long, playbackSpeed: Float, ): Int { - val remainingProgress = (1f - progress).coerceAtLeast(0f) - return (remainingProgress * durationMs / playbackSpeed.coerceAtLeast(0.01f)) + if (!progress.isFinite() || !playbackSpeed.isFinite() || durationMs <= 0L) return 0 + val remainingProgress = (1f - progress).coerceIn(0f, 1f) + val speed = playbackSpeed.coerceAtLeast(0.01f) + return (remainingProgress * durationMs / speed) .roundToInt() .coerceAtLeast(0) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt index 093791a71f..d7bba88d2f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDropdown.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes @@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt index 61dff418b1..b48cdd8661 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.plus import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.heading @@ -68,7 +67,6 @@ fun PreferencePage( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PreferenceTopAppBar( title: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt index 80ef3b9b26..3e0bb4059c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt @@ -32,9 +32,6 @@ val SemanticColors.roomListRoomMessage val SemanticColors.roomListRoomMessageDate get() = textSecondary -val SemanticColors.unreadIndicator - get() = iconAccentTertiary - val SemanticColors.placeholderBackground get() = bgSubtleSecondary @@ -83,7 +80,6 @@ internal fun ColorAliasesPreview() = ElementPreview { "roomListRoomName" to ElementTheme.colors.roomListRoomName, "roomListRoomMessage" to ElementTheme.colors.roomListRoomMessage, "roomListRoomMessageDate" to ElementTheme.colors.roomListRoomMessageDate, - "unreadIndicator" to ElementTheme.colors.unreadIndicator, "placeholderBackground" to ElementTheme.colors.placeholderBackground, "messageFromMeBackground" to ElementTheme.colors.messageFromMeBackground, "messageFromOtherBackground" to ElementTheme.colors.messageFromOtherBackground, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt index 299744cebf..a33bedc3c8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Button.kt @@ -109,6 +109,7 @@ fun TextButton( showProgress: Boolean = false, destructive: Boolean = false, leadingIcon: IconSource? = null, + colors: ButtonColors = ButtonStyle.Text.getColors(destructive), ) = ButtonInternal( text = text, onClick = onClick, @@ -118,7 +119,8 @@ fun TextButton( size = size, showProgress = showProgress, destructive = destructive, - leadingIcon = leadingIcon + leadingIcon = leadingIcon, + colors = colors, ) @Composable diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt index 8cdb320d30..cf881d9b93 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalFloatingToolbar.kt @@ -8,17 +8,17 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FloatingToolbarColors import androidx.compose.material3.FloatingToolbarDefaults import androidx.compose.material3.FloatingToolbarHorizontalFabPosition @@ -28,7 +28,6 @@ import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape @@ -37,7 +36,7 @@ import androidx.compose.ui.unit.Dp 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.libraries.designsystem.atomic.atoms.CounterAtom +import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.components.tooltip.PlainTooltip import io.element.android.libraries.designsystem.components.tooltip.TooltipBox import io.element.android.libraries.designsystem.preview.ElementPreview @@ -46,7 +45,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight /** * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4457-1136 */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun HorizontalFloatingToolbar( modifier: Modifier = Modifier, @@ -139,8 +137,18 @@ fun HorizontalFloatingToolbarItem( contentColor = ElementTheme.colors.iconSecondary, ) } - Box { - FilledIconButton( + BadgedBox( + badge = { + counter?.let { + UnreadIndicatorAtom( + count = it.toLong(), + // Tweak the offset so it's centered on the icon + modifier = Modifier.offset(x = (-16).dp, y = 10.dp), + ) + } + } + ) { + IconButton( modifier = Modifier.widthIn(min = 56.dp), colors = colors, onClick = onClick, @@ -151,15 +159,6 @@ fun HorizontalFloatingToolbarItem( contentDescription = tooltipLabel, ) } - if (counter != null) { - CounterAtom( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 6.dp, end = 3.dp), - count = counter, - textStyle = ElementTheme.typography.fontBodyXsMedium, - ) - } } } } @@ -172,18 +171,47 @@ fun HorizontalFloatingToolbarSeparator(modifier: Modifier = Modifier) { @PreviewsDayNight @Composable internal fun HorizontalFloatingToolbarPreview() = ElementPreview { - ContentToPreview( - floatingActionButton = { - FloatingActionButton( - onClick = {}, - ) { - Icon( - imageVector = CompoundIcons.Plus(), - contentDescription = null, - ) + Column { + ContentToPreview( + count = 1, + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } } - } - ) + ) + ContentToPreview( + count = 99, + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } + } + ) + ContentToPreview( + count = 999, + floatingActionButton = { + FloatingActionButton( + onClick = {}, + ) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + ) + } + } + ) + } } @PreviewsDayNight @@ -194,13 +222,13 @@ internal fun HorizontalFloatingToolbarNoFabPreview() = ElementPreview { ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable private fun ContentToPreview( + count: Int? = 6, floatingActionButton: (@Composable () -> Unit)?, ) { HorizontalFloatingToolbar( - modifier = Modifier.padding(28.dp), + modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp), floatingActionButton = floatingActionButton, ) { listOf( @@ -214,7 +242,7 @@ private fun ContentToPreview( icon = icon, tooltipLabel = "Label", isSelected = index == 0, - counter = if (index == 0) 6 else null, + counter = if (index == 0) count else null, forceRenderingTooltip = true, onClick = { }, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt index a49a3595f7..8ee219356d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/MediumTopAppBar.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults @@ -25,7 +24,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediumTopAppBar( title: @Composable () -> Unit, @@ -51,7 +49,6 @@ fun MediumTopAppBar( ) } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.AppBars) @Composable internal fun MediumTopAppBarPreview() = ElementThemedPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt index 7c6332bd51..58e55f5599 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarIcon.kt @@ -8,12 +8,10 @@ package io.element.android.libraries.designsystem.theme.components -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset +import androidx.compose.material3.BadgedBox import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom @@ -24,16 +22,19 @@ fun NavigationBarIcon( count: Int = 0, isCritical: Boolean = false, ) { - Box(modifier) { + BadgedBox( + modifier = modifier, + badge = { + CounterAtom( + textStyle = ElementTheme.typography.fontBodyXsMedium, + count = count, + isCritical = isCritical, + ) + } + ) { Icon( imageVector = imageVector, contentDescription = null, ) - CounterAtom( - modifier = Modifier.offset(11.dp, (-11).dp), - textStyle = ElementTheme.typography.fontBodyXsMedium, - count = count, - isCritical = isCritical, - ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt index 28a62a2c36..0137c86870 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SearchBar.kt @@ -79,6 +79,9 @@ fun SearchBar( } } + // TODO Migrate to the SearchBarState API. The replacement (ExpandedFullScreenSearchBar) displays the results in a dialog, + // which would change the UX, in particular for the emoji picker bottom sheet. + @Suppress("DEPRECATION") SearchBar( inputField = { SearchBarDefaults.InputField( @@ -156,7 +159,6 @@ fun SearchBar( } object ElementSearchBarDefaults { - @OptIn(ExperimentalMaterial3Api::class) @Composable fun inactiveColors() = SearchBarDefaults.colors( containerColor = Color.Transparent, @@ -176,7 +178,6 @@ object ElementSearchBarDefaults { errorContainerColor = ElementTheme.colors.bgSubtleSecondary, ) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun activeColors() = SearchBarDefaults.colors( containerColor = Color.Transparent, @@ -286,7 +287,6 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable @ExcludeFromCoverage private fun ContentToPreview( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt index 7fd195bd49..fb59be0094 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/SegmentedButton.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRowScope @@ -19,7 +18,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import io.element.android.compound.theme.ElementTheme -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SingleChoiceSegmentedButtonRowScope.SegmentedButton( index: Int, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt index 7b6e6f3062..f8ff23c8bc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.interaction.DragInteraction @@ -15,9 +13,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -60,16 +58,16 @@ fun Slider( } } } + val sliderState = remember(steps, valueRange) { SliderState(value = value, steps = steps, trackRange = valueRange) } + sliderState.value = value androidx.compose.material3.Slider( - value = value, + state = sliderState, onValueChange = onValueChange, modifier = modifier, enabled = enabled, - valueRange = valueRange, - steps = steps, onValueChangeFinished = onValueChangeFinish, colors = colors, - thumb = { + thumb = { _ -> if (useCustomLayout) { SliderDefaults.Thumb( modifier = Modifier.drawWithContent { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt index d6c876d243..31fde41a50 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TopAppBar.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalContentColor import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults @@ -38,7 +37,6 @@ import io.element.android.libraries.designsystem.theme.aliasScreenTitle * @param colors The colors used for this top app bar. * @param scrollBehavior Optional scroll behavior for this top app bar. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TopAppBar( titleStr: String, @@ -78,7 +76,6 @@ fun TopAppBar( * @param colors The colors used for this top app bar. * @param scrollBehavior Optional scroll behavior for this top app bar. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TopAppBar( title: @Composable () -> Unit, @@ -104,7 +101,6 @@ fun TopAppBar( ) } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.AppBars) @Composable internal fun TopAppBarPreview() = ElementThemedPreview { @@ -123,7 +119,6 @@ internal fun TopAppBarPreview() = ElementThemedPreview { ) } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.AppBars) @Composable internal fun TopAppBarStrPreview() = ElementThemedPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt index b5dfe42587..0ce9f0169e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/DatePickerPreview.kt @@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components.previews import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.DatePicker -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview @@ -33,7 +32,6 @@ internal fun DatePickerDarkPreview() { ElementPreviewDark { ContentToPreview() } } -@OptIn(ExperimentalMaterial3Api::class) @ExcludeFromCoverage @Composable private fun ContentToPreview() { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt index f4562f1058..bdc15222e0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/previews/TimePickerPreview.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.theme.components.previews import androidx.compose.material3.AlertDialogDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePickerLayoutType import androidx.compose.material3.rememberTimePickerState @@ -22,7 +21,6 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.AlertDialogContent -@OptIn(ExperimentalMaterial3Api::class) @Preview(widthDp = 600, group = PreviewGroup.DateTimePickers) @Composable internal fun TimePickerHorizontalPreview() { @@ -44,7 +42,6 @@ internal fun TimePickerHorizontalPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.DateTimePickers) @Composable internal fun TimePickerVerticalLightPreview() { @@ -66,7 +63,6 @@ internal fun TimePickerVerticalLightPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Preview(group = PreviewGroup.DateTimePickers) @Composable internal fun TimePickerVerticalDarkPreview() { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.kt new file mode 100644 index 0000000000..60db43ecb3 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ActivationKeys.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.designsystem.utils + +import androidx.compose.ui.input.key.Key + +/** + * Keys which can be used to activate (i.e. click) the component having the keyboard focus. + */ +val activationKeys = setOf( + Key.Enter, + Key.NumPadEnter, + Key.DirectionCenter, + Key.Spacebar, +) diff --git a/libraries/emoji/api/build.gradle.kts b/libraries/emoji/api/build.gradle.kts index 48143d9512..d9e56fb68f 100644 --- a/libraries/emoji/api/build.gradle.kts +++ b/libraries/emoji/api/build.gradle.kts @@ -15,7 +15,6 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(libs.kotlinx.collections.immutable) api(libs.matrix.emojibase.bindings) diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 59a1ad6575..844f8ce403 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -30,7 +30,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.uiStrings) implementation(libs.kotlinx.collections.immutable) implementation(libs.matrix.emojibase.bindings) 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 c41cbb3b57..e748628d66 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 @@ -21,7 +21,6 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.runtime.Composable @@ -47,7 +46,6 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun EmojiPickerView( state: DefaultEmojiPickerState, diff --git a/libraries/emoji/impl/src/main/res/values-ro/translations.xml b/libraries/emoji/impl/src/main/res/values-ro/translations.xml index 410bcef3bc..b8d73e1d89 100644 --- a/libraries/emoji/impl/src/main/res/values-ro/translations.xml +++ b/libraries/emoji/impl/src/main/res/values-ro/translations.xml @@ -2,12 +2,12 @@ "Activități" "Steaguri" - "Mâncare & Băutură" - "Animale și Natură" + "Mâncare și băutură" + "Animale și natură" "Obiecte" - "Fețe zâmbitoare & Oameni" - "Călătorii & Locuri" - "Emoticoane recente" + "Emoticoane și oameni" + "Călătorii și locuri" + "Emojiuri recente" "Simboluri" - "Căutați emoticoane" + "Căutați emojiuri" diff --git a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml index 6465d3299a..7d2f7b8d55 100644 --- a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml @@ -1,6 +1,6 @@ - "(s-a schimbat si avatarul)" + "(s-a schimbat și avatarul)" "%1$s și-a schimbat avatarul" "V-ați schimbat avatarul" "%1$s a fost retrogradat la funcția de membru" @@ -11,7 +11,7 @@ "V-ați șters numele (era %1$s)" "%1$s și-a schimbat numele %2$s" "V-ați schimbat numele în %1$s" - "%1$s a fost promovat în funcția de administrator" + "%1$s a fost promovat la funcția de administrator" "%1$s a fost promovat la funcția de moderator" "%1$s a schimbat avatarul camerei" "Ați schimbat avatarul camerei" @@ -28,15 +28,15 @@ "Ați acceptat invitația" "L-ați invitat pe %1$s" "%1$s v-a invitat" - "%1$s a intrat în cameră" - "Ați intrat în cameră" + "%1$s s-a alăturat camerei" + "V-ați alăturat camerei" "%1$s a cerut să se alăture camerei" "%1$s i-a permis accesul lui %2$s" "I-ați permis lui %1$s să se alăture" "Ați cerut să vă alăturați camerei" "%1$s a respins cererea de alăturare a lui %2$s" "Ați respins cererea de alăturare a lui %1$s" - "%1$s a respins cererea dumneavoastră de alăturare" + "%1$s a respins cererea dvs. de alăturare" "%1$s nu mai este interesat să se alăture camerei" "Ați anulat cererea de alăturare" "%1$s a părăsit camera" @@ -51,8 +51,8 @@ "Ați schimbat mesajele fixate" "%1$s a fixat un mesaj" "Ați fixat un mesaj" - "%1$s a defixat un mesaj" - "Ați defixat un mesaj" + "%1$s a anulat fixarea unui mesaj" + "Ați anulat fixarea unui mesaj" "%1$s a respins invitația" "Ați respins invitația" "%1$s l-a îndepărtat pe %2$s" diff --git a/libraries/featureflag/api/build.gradle.kts b/libraries/featureflag/api/build.gradle.kts index b34e082cd3..f9177a23bc 100644 --- a/libraries/featureflag/api/build.gradle.kts +++ b/libraries/featureflag/api/build.gradle.kts @@ -15,7 +15,6 @@ android { } dependencies { - implementation(projects.appconfig) implementation(projects.libraries.core) implementation(libs.coroutines.core) } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 98bb55fecf..61124c6ce3 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -121,14 +121,14 @@ enum class FeatureFlags( title = "Automatic back pagination of rooms", description = "Allow the app to automatically back paginate in rooms to pre-fetch older messages in background." + "\nRequires an app restart to take effect.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), UnreadIndicatorCount( key = "feature.unread_indicator_count", title = "Unread indicator count", description = "Show the number of unread messages on the unread indicator in the room list.", - defaultValue = { false }, + defaultValue = { true }, isFinished = false, ), SendGalleryMessages( @@ -151,8 +151,16 @@ enum class FeatureFlags( key = "feature.message_search", title = "Message search", description = "Index messages locally so they can be searched. Only messages received while enabled are indexed.", + defaultValue = { true }, + isFinished = false, + ), + MessageMultiSelect( + key = "feature.message_multi_select", + title = "Multi-select messages", + description = "The Forward action enters a selection mode, to pick and forward up to 10 messages at once.", defaultValue = { false }, isFinished = false, + isInLabs = false, ), NewTimelineEventRenderer( key = "feature.new_timeline_event_renderer", @@ -162,4 +170,26 @@ enum class FeatureFlags( isFinished = false, isInLabs = false, ), + NativeCall( + key = "feature.native_call", + title = "Native calls (experimental)", + description = "Place calls with the native MatrixRTC stack instead of the Element Call WebView.", + defaultValue = { false }, + isFinished = false, + ), +} + +/** + * Feature flag to show all activity indicators in the room list, using unread messages instead of notifications. + * + * This is separate from the [FeatureFlags] enum because it is not a feature flag that can be toggled in developer options, + * but rather a setting that can be toggled in the app settings. + */ +object ShowAllActivityInRoomListFeature : Feature { + override val key: String = "feature.show_all_activity_in_room_list" + override val title: String = "Show all activity in room list" + override val description: String = "Show all activity indicators in the room list, using unread messages instead of notifications." + override val defaultValue: (BuildMeta) -> Boolean = { true } + override val isFinished: Boolean = false + override val isInLabs: Boolean = false } diff --git a/libraries/featureflag/impl/build.gradle.kts b/libraries/featureflag/impl/build.gradle.kts index 63d968467a..6d2db6f365 100644 --- a/libraries/featureflag/impl/build.gradle.kts +++ b/libraries/featureflag/impl/build.gradle.kts @@ -23,9 +23,6 @@ setupDependencyInjection() dependencies { api(projects.libraries.featureflag.api) implementation(libs.androidx.datastore.preferences) - implementation(projects.appconfig) - implementation(projects.libraries.di) - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.preferences.api) implementation(libs.coroutines.core) diff --git a/libraries/fullscreenintent/api/build.gradle.kts b/libraries/fullscreenintent/api/build.gradle.kts index 8d6be454ea..e8ad77c3a9 100644 --- a/libraries/fullscreenintent/api/build.gradle.kts +++ b/libraries/fullscreenintent/api/build.gradle.kts @@ -16,6 +16,4 @@ android { dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.preferences.api) } diff --git a/libraries/fullscreenintent/impl/build.gradle.kts b/libraries/fullscreenintent/impl/build.gradle.kts index 57c4f5b836..326411a635 100644 --- a/libraries/fullscreenintent/impl/build.gradle.kts +++ b/libraries/fullscreenintent/impl/build.gradle.kts @@ -23,16 +23,12 @@ dependencies { api(projects.libraries.fullscreenintent.api) implementation(projects.libraries.core) implementation(projects.libraries.architecture) - implementation(projects.libraries.permissions.api) - implementation(projects.libraries.permissions.noop) implementation(projects.libraries.preferences.api) implementation(projects.services.toolbox.api) implementation(libs.androidx.datastore.preferences) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) - testImplementation(projects.libraries.testtags) testImplementation(projects.services.toolbox.test) } diff --git a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt index 7d726ccdcc..201bc65e79 100644 --- a/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt +++ b/libraries/fullscreenintent/impl/src/main/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenter.kt @@ -15,11 +15,15 @@ import android.provider.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.lifecycle.compose.LifecycleResumeEffect import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -61,9 +65,15 @@ class FullScreenIntentPermissionsPresenter( @Composable override fun present(): FullScreenIntentPermissionsState { val coroutineScope = rememberCoroutineScope() - val isGranted = notificationManagerCompat.canUseFullScreenIntent() + var isGranted by remember { mutableStateOf(notificationManagerCompat.canUseFullScreenIntent()) } val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true) + // The permission can be granted from the system settings, so check it again when the user comes back to the app. + LifecycleResumeEffect(Unit) { + isGranted = notificationManagerCompat.canUseFullScreenIntent() + onPauseOrDispose {} + } + fun handleEvent(event: FullScreenIntentPermissionsEvent) { when (event) { FullScreenIntentPermissionsEvent.Dismiss -> coroutineScope.launch { diff --git a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt similarity index 76% rename from libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt rename to libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt index 08bc2ed885..593fd71492 100644 --- a/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/test/FullScreenIntentPermissionsPresenterTest.kt +++ b/libraries/fullscreenintent/impl/src/test/kotlin/io/element/android/libraries/fullscreenintent/impl/FullScreenIntentPermissionsPresenterTest.kt @@ -6,25 +6,24 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.fullscreenintent.test +package io.element.android.libraries.fullscreenintent.impl import android.content.Intent import android.os.Build import androidx.core.app.NotificationManagerCompat -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test +import androidx.lifecycle.Lifecycle import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent -import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.FakeLifecycleOwner import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testWithLifecycleOwner import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,9 +44,7 @@ class FullScreenIntentPermissionsPresenterTest { every { canUseFullScreenIntent() } returns false } ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val initialItem = awaitItem() assertThat(initialItem.shouldDisplayBanner).isTrue() @@ -61,21 +58,39 @@ class FullScreenIntentPermissionsPresenterTest { every { canUseFullScreenIntent() } returns true } ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val initialItem = awaitItem() assertThat(initialItem.shouldDisplayBanner).isFalse() } } + @Test + fun `shouldDisplay - becomes false when permission is granted while the app is in background`() = runTest { + var canUseFullScreenIntent = false + val presenter = createPresenter( + notificationManagerCompat = mockk { + every { canUseFullScreenIntent() } answers { canUseFullScreenIntent } + } + ) + val lifecycleOwner = FakeLifecycleOwner(Lifecycle.State.RESUMED) + presenter.testWithLifecycleOwner(lifecycleOwner) { + skipItems(1) + assertThat(awaitItem().shouldDisplayBanner).isTrue() + // User opens the system settings and grants the permission + lifecycleOwner.givenState(Lifecycle.State.STARTED) + canUseFullScreenIntent = true + lifecycleOwner.givenState(Lifecycle.State.RESUMED) + val finalItem = awaitItem() + assertThat(finalItem.permissionGranted).isTrue() + assertThat(finalItem.shouldDisplayBanner).isFalse() + } + } + @Test fun `dismissFullScreenIntentBanner - makes shouldDisplay false`() = runTest { val presenter = createPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.Dismiss) @@ -89,9 +104,7 @@ class FullScreenIntentPermissionsPresenterTest { val launchLambda = lambdaRecorder { _ -> } val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda) val presenter = createPresenter(externalIntentLauncher = externalIntentLauncher) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) @@ -108,9 +121,7 @@ class FullScreenIntentPermissionsPresenterTest { buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.Q), externalIntentLauncher = externalIntentLauncher, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.testWithLifecycleOwner { skipItems(1) val loadedItem = awaitItem() loadedItem.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index a8daab7a90..283aa356be 100644 --- a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -13,7 +13,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,6 +33,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.SideEffect @@ -51,6 +55,7 @@ import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard @@ -66,6 +71,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.ResolvedTextDirection import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -215,7 +221,7 @@ private fun ParagraphView( TextBlock( text = node.text, inlineContent = node.inlineContent, - textStyle = ElementTheme.typography.fontBodyMdRegular, + textStyle = LocalTextStyle.current, context = context, modifier = modifier, onTextLayout = { measureLastLine?.invoke(it) }, @@ -327,10 +333,10 @@ private fun CodeBlockView( ) // Previews and screenshots can't render the fading edge gradients properly, everything is obscured by them, so we skip them in the preview mode. - if (LocalInspectionMode.current.not()) { + if (LocalInspectionMode.current.not() && (scrollState.canScrollForward || scrollState.canScrollBackward)) { val progress by remember { derivedStateOf { - scrollState.value.toFloat() / scrollState.maxValue.coerceAtLeast(1) + scrollState.value.toFloat() / scrollState.maxValue } } @@ -509,7 +515,7 @@ private fun Modifier.drawInlineCodeBackgrounds( val lineEnd = minOf(range.end, layout.getLineEnd(line, visibleEnd = true)) if (lineEnd <= lineStart) continue val startX = layout.getHorizontalPosition(lineStart, usePrimaryDirection = true) - val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true) + val endX = layout.getHorizontalPosition(lineEnd, usePrimaryDirection = true).takeUnless { it == 0f } ?: layout.getLineEndPosition(line) val left = minOf(startX, endX) - horizontalPadding val right = maxOf(startX, endX) + horizontalPadding val topLeft = Offset(left, layout.getLineTop(line)) @@ -536,21 +542,44 @@ private fun Modifier.drawInlineCodeBackgrounds( } } +private fun TextLayoutResult.getLineEndPosition(line: Int): Float { + val lineEnd = getLineEnd(line, visibleEnd = true) + val direction = getParagraphDirection(lineEnd) + return if (direction == ResolvedTextDirection.Ltr) { + getLineRight(line) + } else { + getLineLeft(line) + } +} + +/** + * Detects taps and long presses on link-annotated ranges ([LINK_ANNOTATION_TAG]) without consuming the touch events if none are detected, and invokes the + * appropriate callbacks in [context]. + */ private fun Modifier.linkTapHandler( text: AnnotatedString, layoutResult: State, context: RenderContext, ): Modifier = pointerInput(text) { - detectTapGestures( - onTap = { offset -> - val (url, urlText) = layoutResult.value?.urlAt(offset, text) ?: return@detectTapGestures - context.onLinkClick(url, urlText) - }, - onLongPress = { offset -> - val (url, urlText) = layoutResult.value?.urlAt(offset, text) ?: return@detectTapGestures + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = true) + val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture + down.consume() + + // Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event. + if (awaitLongPressOrCancellation(down.id) != null) { context.onLinkLongClick(url, urlText) - }, - ) + // Swallow the rest of the gesture so parents don't react to the eventual release. + waitForUpOrCancellation()?.consume() + } else { + // Either a tap (the pointer was released) or a drag (the user probably wanted to scroll). + val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() } + if (up != null) { + up.consume() + context.onLinkClick(url, urlText) + } + } + } } private fun TextLayoutResult.urlAt(offset: Offset, text: AnnotatedString): Pair? { @@ -563,7 +592,7 @@ private val BlockSpacing: Dp = 8.dp private val ListItemSpacing: Dp = 4.dp private val ListMarkerWidth: Dp = 24.dp private val PillPaddingHorizontal: Dp = 6.dp -private val PillPaddingVertical: Dp = 2.dp +private val PillPaddingVertical: Dp = 0.dp /** Rounded, bordered box drawn behind inline code. */ private val InlineCodeCornerRadius: Dp = 6.dp diff --git a/libraries/htmlrenderer/impl/build.gradle.kts b/libraries/htmlrenderer/impl/build.gradle.kts index ba17d855f1..3aee77ded6 100644 --- a/libraries/htmlrenderer/impl/build.gradle.kts +++ b/libraries/htmlrenderer/impl/build.gradle.kts @@ -27,7 +27,6 @@ setupDependencyInjection() dependencies { api(projects.libraries.htmlrenderer.api) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.uiCommon) implementation(libs.jsoup) testCommonDependencies(libs) diff --git a/libraries/indicator/api/build.gradle.kts b/libraries/indicator/api/build.gradle.kts index c6f33ec83f..a53cfb7707 100644 --- a/libraries/indicator/api/build.gradle.kts +++ b/libraries/indicator/api/build.gradle.kts @@ -13,7 +13,3 @@ plugins { android { namespace = "io.element.android.libraries.indicator.api" } - -dependencies { - implementation(libs.coroutines.core) -} diff --git a/libraries/indicator/impl/build.gradle.kts b/libraries/indicator/impl/build.gradle.kts index 8f1e9ea56d..4b723b6e75 100644 --- a/libraries/indicator/impl/build.gradle.kts +++ b/libraries/indicator/impl/build.gradle.kts @@ -21,7 +21,6 @@ android { dependencies { implementation(projects.libraries.di) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(libs.coroutines.core) @@ -29,6 +28,5 @@ dependencies { api(projects.libraries.indicator.api) testCommonDependencies(libs) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) } diff --git a/libraries/indicator/test/build.gradle.kts b/libraries/indicator/test/build.gradle.kts index a5ce0c57c2..e97c6f5513 100644 --- a/libraries/indicator/test/build.gradle.kts +++ b/libraries/indicator/test/build.gradle.kts @@ -15,6 +15,5 @@ android { } dependencies { - implementation(projects.libraries.matrix.api) api(projects.libraries.indicator.api) } diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index d0790a6905..8387c1fa26 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -53,8 +53,8 @@ dependencies { implementation(libs.serialization.json) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.sessionStorage.api) implementation(projects.services.analytics.api) + implementation(projects.libraries.sessionStorage.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) 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 e43f362e7c..1260c9ec82 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 @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillService 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.SyncService @@ -50,9 +51,15 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.verification.SessionVerificationService import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import java.util.Optional +import kotlin.time.Duration.Companion.milliseconds /** * Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations. @@ -133,6 +140,9 @@ interface MatrixClient : ClientUrlContentFetcher { /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + /** Backfills messages from the homeserver for searching for messages in a room. */ + val searchBackfillService: SearchBackfillService + /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */ val isShuttingDown: Boolean @@ -406,7 +416,7 @@ interface MatrixClient : ClientUrlContentFetcher { /** * 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. + * 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. @@ -501,6 +511,12 @@ interface MatrixClient : ClientUrlContentFetcher { /** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */ fun homeserverCapabilities(): HomeserverCapabilitiesProvider + + /** + * Returns the total number of unread notifications across all rooms, as currently known by the client. + * This is a snapshot and may be out of date if the sync is running. + */ + fun totalUnreadCount(): Result } /** @@ -513,3 +529,18 @@ fun MatrixClient.roomAliasFromName(name: String): RoomAlias? { ?.takeIf { MatrixPatterns.isRoomAlias(it) } ?.let { tryOrNull { RoomAlias(it) } } } + +/** + * Returns a flow of the total number of unread notifications across all rooms. + * The first value is emitted immediately, then the value is refreshed on each sync heartbeat. + * In case of error, 0 is emitted. + */ +@OptIn(FlowPreview::class) +fun MatrixClient.totalUnreadCountFlow(): Flow { + return syncService.heartBeat + // Delay the heartbeat so the SDK has time to update the unread count before we read it. + .debounce(100.milliseconds) + .onStart { emit(Unit) } + .map { totalUnreadCount().getOrDefault(0).toInt() } + .distinctUntilChanged() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt new file mode 100644 index 0000000000..d1c74400c9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt @@ -0,0 +1,16 @@ +/* + * 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 + +/** + * Represents a pending task in the SDK that can be canceled and closed. + */ +interface SdkPendingTask : AutoCloseable { + /** Checks if the task is still running. */ + fun isRunning(): Boolean +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/SdkStoreSizes.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/SdkStoreSizes.kt index 62b68b8fdb..e04128d624 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/SdkStoreSizes.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/SdkStoreSizes.kt @@ -17,4 +17,5 @@ data class SdkStoreSizes( val eventCacheStore: ByteSize?, val mediaStore: ByteSize?, val cryptoStore: ByteSize?, + val searchIndex: ByteSize?, ) 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 e036a198df..40f6ef20a2 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 @@ -123,6 +123,11 @@ interface EncryptionService { * A handle to reset the user's identity. */ sealed interface IdentityResetHandle { + /** + * The value of the session key given by the homeserver. + */ + val session: String + /** * Cancel the reset process and drops the existing handle in the SDK. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt new file mode 100644 index 0000000000..e02b9d3f0b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt @@ -0,0 +1,50 @@ +/* + * 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.search + +import io.element.android.libraries.matrix.api.SdkPendingTask + +/** + * Service interface for managing search backfill tasks in the Matrix SDK. + * This service allows starting, checking the status of, and canceling search backfill tasks. + */ +interface SearchBackfillService { + /** + * Enqueues a periodic search backfill task based on the provided strategy. + * The strategy parameter determines how the backfill should be performed (e.g., in the foreground or background). + */ + suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result + + /** + * Cancels any currently scheduled periodic search backfill tasks. + */ + fun cancelPeriodicSearchBackfill() + + /** + * Starts a background task to backfill messages for rooms that have been joined but not fully backfilled yet. + * The strategy parameter determines how the backfill should be performed (e.g., in the foreground or background). + * + * @param strategy the strategy to use for backfilling messages. + * @return a [Result] indicating the success or failure of enqueuing the backfill task. + */ + fun startSearchBackfill(strategy: SearchBackfillStrategy): Result + + /** + * Checks if a search backfill task is currently running. + * + * @return `true` if a search backfill task is running, `false` otherwise. + */ + fun isSearchBackfillRunning(): Boolean + + /** + * Cancels the currently running search backfill task, if any. + * + * @return a [Result] indicating the success or failure of the cancellation operation. + */ + fun cancelSearchBackfill(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt new file mode 100644 index 0000000000..c22b0e12bb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.search + +enum class SearchBackfillStrategy { + /** + * The app is in the foreground: pause between paginations so this doesn't + * compete with interactive traffic. + */ + FOREGROUND, + + /** + * A time-boxed background task (e.g. iOS `BGAppRefreshTask`) where there's + * no interactive traffic to protect. + */ + BACKGROUND +} 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 92f60352d7..25fd1475f1 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 @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api.sync +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -37,4 +38,10 @@ interface SyncService { * This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online. */ val isOnline: StateFlow + + /** + * Flow that will emit every time the sync has received a response from the server, even if it was empty. + * Useful for triggering UI updates when the sync is running. + */ + val heartBeat: Flow } 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 72483e68f1..d26d8cc00c 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 @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId @@ -316,12 +317,13 @@ interface Timeline : AutoCloseable { 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. + * Forwards the content of some events to other rooms, as new messages sent by the current user. + * The events are forwarded in the order of [eventIds]. * - * @param eventId the event whose content is forwarded. - * @param roomIds the rooms to forward it to. + * @param eventIds the events whose content is forwarded. + * @param roomIds the rooms to forward them to. */ - suspend fun forwardEvent(eventId: EventId, roomIds: List): Result + suspend fun forwardEvents(eventIds: List, roomIds: List): Result /** * Cancels an event that is still waiting in the send queue, by redacting its local echo. @@ -422,4 +424,20 @@ interface Timeline : AutoCloseable { * Get the latest event id of the timeline, or `null` when it holds no event yet. */ suspend fun getLatestEventId(): Result + + /** + * Retries sending an event that failed to send, by putting it back in the send queue. + * + * @param eventOrTransactionId the event or transaction ID of the event to retry sending. + * @param sendTarget the target action of the send operation. + */ + suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result + + /** + * Aborts sending an event that failed to send, by removing it from the send queue. + * + * @param eventOrTransactionId the event or transaction ID of the event to abort sending. + * @param sendTarget the target action of the send operation. + */ + suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt new file mode 100644 index 0000000000..b882636e8e --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/SendTarget.kt @@ -0,0 +1,28 @@ +/* + * 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.timeline.item + +import androidx.compose.runtime.Immutable + +/** + * Represents the target action of a send operation in a timeline. + */ +@Immutable +sealed interface SendTarget { + /** Send a new event. */ + data object Event : SendTarget + + /** Edit an existing event. */ + data object Edit : SendTarget + + /** Redact an existing event. */ + data object Redaction : SendTarget + + /** React to an existing event with the given key. */ + data class Reaction(val key: String) : SendTarget +} 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 8a3221677d..c2c110dd5f 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 @@ -24,6 +24,8 @@ data class EventTimelineItem( val isOwn: Boolean, val isRemote: Boolean, val localSendState: LocalEventSendState?, + val editSendState: LocalEventSendState?, + val redactionSendState: LocalEventSendState?, val reactions: ImmutableList, val receipts: ImmutableList, val sender: UserId, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 7a8ec739a5..f27557bda2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -19,4 +19,6 @@ data class MatrixUser( val avatarUrl: String? = null, val displayedStatus: DisplayedStatus? = null, val rawStatus: UserStatus? = null, + /** Base64 encoded thumbnail of [avatarUrl], stored locally, used as a fallback when the avatar cannot be loaded. */ + val avatarThumbnail: String? = null, ) : Parcelable diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.kt new file mode 100644 index 0000000000..136dbf58c3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/SessionDataExtensions.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.libraries.matrix.api.user + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.sessionstorage.api.SessionData + +/** + * Create a [MatrixUser] from the profile data stored locally for the session. + * The avatar thumbnail is used as a fallback when the avatar cannot be loaded, + * for instance for a session which is not the current one. + */ +fun SessionData.toMatrixUser() = MatrixUser( + userId = UserId(userId), + displayName = userDisplayName, + avatarUrl = userAvatarUrl, + avatarThumbnail = userAvatarData, +) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 2499b720e3..17f5c37ef9 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -20,24 +20,59 @@ android { setupDependencyInjection() +// Temporary: `-PelementCallLocalVersion` points this at a locally published build of +// element-call-android, because the library has no remote repository yet. Goes with the mavenLocal +// block in settings.gradle.kts. Only the BOM carries a version; every other artifact follows it. +val elementCallVersion: String = providers.gradleProperty("elementCallLocalVersion") + .getOrElse(libs.versions.element.call.get()) + +val usesLocalRustSdk = file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists() + +if (usesLocalRustSdk) { + // element-call-matrix declares the Rust SDK as an ordinary Maven dependency, so with the local + // AAR in place a debug classpath would carry two copies of org.matrix.rustcomponents.sdk. + // Point the library's dependency at the same local AAR the app is using. + configurations.matching { it.name.startsWith("debug") }.configureEach { + resolutionStrategy.dependencySubstitution { + substitute(module("org.matrix.rustcomponents:sdk-android")) + .using(project(":libraries:rustsdk")) + .because("the local Rust SDK AAR replaces the published one on debug builds") + } + } +} + dependencies { releaseImplementation(libs.matrix.sdk) - if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) { - println("\nNote: Using local binary of the Rust SDK.\n") + if (usesLocalRustSdk) { + println( + "\nNote: Using local binary of the Rust SDK." + + "\n element-call-matrix is substituted onto it as well, so the native call runs" + + "\n against this SDK rather than the one it was published against.\n" + ) debugImplementation(projects.libraries.rustsdk) } else { debugImplementation(libs.matrix.sdk) } - implementation(projects.libraries.rustlsTls) + implementation(variantOf(libs.rustls.platform.verifier) { artifactType("aar") }) + + // The native call's Matrix transport. Only this module may hand it a raw SDK client. + implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) + implementation(libs.element.call.api) + implementation(projects.features.callnative.api) + implementation(libs.element.call.matrix) { + if (usesLocalRustSdk) { + exclude(group = "org.matrix.rustcomponents", module = "sdk-android") + } + } implementation(projects.appconfig) implementation(projects.features.enterprise.api) + implementation(projects.features.networkmonitor.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.di) implementation(projects.libraries.featureflag.api) implementation(projects.libraries.network) - implementation(projects.libraries.preferences.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.workmanager.api) implementation(projects.services.analytics.api) @@ -46,14 +81,14 @@ dependencies { implementation(projects.libraries.core) implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(libs.androidx.datastore.preferences) - implementation(libs.serialization.json) implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.lifecycle.process) testCommonDependencies(libs) testImplementation(projects.features.enterprise.test) + testImplementation(projects.features.networkmonitor.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) testImplementation(projects.libraries.sessionStorage.test) testImplementation(projects.libraries.workmanager.test) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/NoOpClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/NoOpClientSessionDelegate.kt new file mode 100644 index 0000000000..4a2a7a4079 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/NoOpClientSessionDelegate.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 + +import org.matrix.rustcomponents.sdk.ClientSessionDelegate +import org.matrix.rustcomponents.sdk.Session + +/** + * A [ClientSessionDelegate] which does nothing, to be used for the clients which are not associated to a stored session, + * for instance the temporary clients, or the clients used to perform a login. The session is only stored once the login + * is done, with a client created by [RustMatrixClientFactory.create], which uses a [RustClientSessionDelegate]. + */ +internal object NoOpClientSessionDelegate : ClientSessionDelegate { + override fun saveSessionInKeychain(session: Session) = Unit + + override fun retrieveSessionFromKeychain(userId: String): Session { + // This should never be called, as it's only used for multi-process setups + error("retrieveSessionFromKeychain should never be called for Android") + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt index a5c69bf831..78fc79f0e7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt @@ -37,6 +37,7 @@ private val loggerTag = LoggerTag("RustClientSessionDelegate") * It implements both [ClientSessionDelegate] and [ClientDelegate] to react to session data updates and auth errors. * * IMPORTANT: you must set the [client] property as soon as possible so [didReceiveAuthError] can work properly. + * Since [didReceiveAuthError] does not tell which session is concerned, each client must have its own instance. */ class RustClientSessionDelegate( private val sessionStore: SessionStore, 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 86414ed6a3..5fca754956 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 @@ -8,6 +8,9 @@ package io.element.android.libraries.matrix.impl +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -16,7 +19,9 @@ import io.element.android.libraries.core.data.bytes import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions +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.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes @@ -31,6 +36,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.oauth.AccountManagementAction import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.api.room.BaseRoom @@ -46,6 +52,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy 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 @@ -80,6 +87,8 @@ import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.search.RustMessageSearchService +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.map 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 @@ -109,15 +118,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import org.matrix.rustcomponents.sdk.AuthData -import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails import org.matrix.rustcomponents.sdk.BeaconInfoListener import org.matrix.rustcomponents.sdk.BeaconInfoUpdate import org.matrix.rustcomponents.sdk.Client @@ -136,6 +146,7 @@ import timber.log.Timber import java.io.File import java.util.Optional import java.util.concurrent.atomic.AtomicBoolean +import kotlin.io.encoding.Base64 import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -143,24 +154,41 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +// Same value as AVATAR_THUMBNAIL_SIZE_IN_PIXEL in the matrixmedia module +private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L + @Suppress("LargeClass") +@AssistedInject class RustMatrixClient( - override val sessionPaths: SessionPaths, - val innerClient: Client, + @Assisted override val sessionPaths: SessionPaths, + @Assisted val innerClient: Client, + @Assisted private val innerSyncService: ClientSyncService, + @Assisted baseCacheDirectory: File, + @Assisted override val contentScanner: ContentScanner?, + @Assisted override val isMessageSearchAvailable: Boolean, + @Assisted private val sessionDelegate: RustClientSessionDelegate, private val sessionStore: SessionStore, - private val sessionDelegate: RustClientSessionDelegate, - private val innerSyncService: ClientSyncService, - appCoroutineScope: CoroutineScope, + @AppCoroutineScope appCoroutineScope: CoroutineScope, dispatchers: CoroutineDispatchers, - baseCacheDirectory: File, clock: SystemClock, timelineEventFilterFactory: TimelineEventFilterFactory, private val featureFlagService: FeatureFlagService, private val analyticsService: AnalyticsService, private val workManagerScheduler: WorkManagerScheduler, - override val contentScanner: ContentScanner?, - override val isMessageSearchAvailable: Boolean, + private val searchBackfillServiceFactory: RustSearchBackfillService.Factory, ) : MatrixClient { + @AssistedFactory + interface Factory { + fun create( + sessionPaths: SessionPaths, + innerClient: Client, + innerSyncService: ClientSyncService, + baseCacheDirectory: File, + contentScanner: ContentScanner?, + isMessageSearchAvailable: Boolean, + sessionDelegate: RustClientSessionDelegate, + ): RustMatrixClient + } override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) override val server: String? = innerClient.server() @@ -275,6 +303,8 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + override val searchBackfillService = searchBackfillServiceFactory.create(innerClient) + private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -289,7 +319,11 @@ class RustMatrixClient( private val ownProfileListener = object : ProfileListener { override fun onUpdate(profile: UserProfile) { - _userProfile.tryEmit(profile.map()) + val matrixUser = profile.map() + _userProfile.tryEmit(matrixUser) + sessionCoroutineScope.launch(sessionDispatcher) { + storeUserProfile(matrixUser) + } } } @@ -323,6 +357,9 @@ class RustMatrixClient( // Schedule regular database vacuuming to ensure DB performance remains optimal scheduleDatabaseVacuum() + + // Schedule background search backfill if needed + scheduleBackgroundSearchBackfill() } private suspend fun setupUserProfile() { @@ -485,13 +522,37 @@ class RustMatrixClient( .onSuccess { matrixUser -> // Also update our session storage _userProfile.emit(matrixUser) - sessionStore.updateUserProfile( - sessionId = sessionId.value, - displayName = matrixUser.displayName, - avatarUrl = matrixUser.avatarUrl, - ) + storeUserProfile(matrixUser) } + /** + * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar, + * so that the avatar can be rendered without having to use this client. + */ + private suspend fun storeUserProfile(matrixUser: MatrixUser) = withContext(sessionDispatcher) { + val avatarUrl = matrixUser.avatarUrl + val storedSession = sessionStore.getSession(sessionId.value) + val avatarData = when { + avatarUrl == null -> null + // Avatar has not changed, no need to download it again + avatarUrl == storedSession?.userAvatarUrl && storedSession.userAvatarData != null -> storedSession.userAvatarData + else -> matrixMediaLoader.loadMediaThumbnail( + source = MediaSource(avatarUrl), + width = AVATAR_THUMBNAIL_SIZE_IN_PIXEL, + height = AVATAR_THUMBNAIL_SIZE_IN_PIXEL, + ) + .map { Base64.encode(it) } + .onFailure { Timber.w(it, "Unable to load the avatar thumbnail of the user") } + .getOrNull() + } + sessionStore.updateUserProfile( + sessionId = sessionId.value, + displayName = matrixUser.displayName, + avatarUrl = avatarUrl, + avatarData = avatarData, + ) + } + override suspend fun searchUsers(searchTerm: String, limit: Long): Result = withContext(sessionDispatcher) { runCatchingExceptions { @@ -654,6 +715,7 @@ class RustMatrixClient( sessionCoroutineScope.cancel() clientDelegateTaskHandle?.cancelAndDestroy() ownProfileTaskHandle?.cancelAndDestroy() + searchBackfillService.cancelSearchBackfill() sessionVerificationService.destroy() sessionDelegate.clearCurrentClient() @@ -675,6 +737,7 @@ class RustMatrixClient( eventCacheStore = eventCacheStore?.bytes, mediaStore = mediaStore?.bytes, cryptoStore = cryptoStore?.bytes, + searchIndex = searchIndex?.bytes, ) } } @@ -731,33 +794,10 @@ class RustMatrixClient( clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle = null runCatchingExceptions { - // First call without AuthData, should fail - val firstAttempt = runCatchingExceptions { - innerClient.deactivateAccount( - authData = null, - eraseData = eraseData, - ) - } - if (firstAttempt.isFailure) { - Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again") - // This is expected, try again with the password - runCatchingExceptions { - innerClient.deactivateAccount( - authData = AuthData.Password( - passwordDetails = AuthDataPasswordDetails( - identifier = sessionId.value, - password = password, - ), - ), - eraseData = eraseData, - ) - }.onFailure { - Timber.e(it, "Failed to deactivate account") - // If the deactivation failed we need to restore the delegate - clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate) - throw it - } - } + innerClient.deactivateAccount( + password = password, + eraseData = eraseData, + ) destroy() deleteSessionDirectory() sessionStore.removeSession(sessionId.value) @@ -781,7 +821,7 @@ class RustMatrixClient( override suspend fun getRoomInfo(roomId: RoomId): Result = withContext(sessionDispatcher) { runCatchingExceptions { - innerRoomListService.roomOrNull(roomId.value)?.use { room -> + innerRoomListService.roomOrNull(roomId)?.use { room -> roomInfoMapper.map(room.roomInfo()) } } @@ -789,7 +829,7 @@ class RustMatrixClient( override fun getRoomInfoFlow(roomId: RoomId): Flow> { return mxCallbackFlow { - val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null } + val roomNotFound = innerRoomListService.roomOrNull(roomId).use { it == null } if (roomNotFound) { channel.send(Optional.empty()) } @@ -814,6 +854,7 @@ class RustMatrixClient( override fun sendQueueDisabledFlow(): Flow = mxCallbackFlow { innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener { override fun onError(roomId: String, error: ClientException) { + Timber.e(error, "Send queue error for room $roomId") trySend(RoomId(roomId)) } }) @@ -959,6 +1000,25 @@ class RustMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) } + + override fun totalUnreadCount(): Result = runCatchingExceptions { + innerClient.totalUnreadNotifications().toLong() + } + + private fun scheduleBackgroundSearchBackfill() { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) + .distinctUntilChanged() + .onEach { isEnabled -> + if (isEnabled) { + searchBackfillService.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + } else { + searchBackfillService.cancelPeriodicSearchBackfill() + } + } + // Checking the pending work is a blocking call, do not do it on the main thread + .flowOn(sessionDispatcher) + .launchIn(sessionCoroutineScope) + } } private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels( 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 9b381460a2..b4fd42762f 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 @@ -23,19 +23,17 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.proxy.ProxyProvider -import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory import io.element.android.libraries.matrix.impl.scanner.RustContentScanner import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.util.anonymizedTokens import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.ClientSessionDelegate import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.Session @@ -61,16 +59,18 @@ class RustMatrixClientFactory( private val sessionStore: SessionStore, private val userAgentProvider: UserAgentProvider, private val proxyProvider: ProxyProvider, - private val clock: SystemClock, private val analyticsService: AnalyticsService, private val featureFlagService: FeatureFlagService, - private val timelineEventFilterFactory: TimelineEventFilterFactory, private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, - private val workManagerScheduler: WorkManagerScheduler, private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook, + private val innerMatrixClientFactory: RustMatrixClient.Factory, ) { - private val sessionDelegate = RustClientSessionDelegate( + /** + * Create a new [RustClientSessionDelegate]. Each client must have its own instance, since the delegate + * keeps a reference to the client it handles the auth errors for. + */ + private fun createSessionDelegate() = RustClientSessionDelegate( sessionStore = sessionStore, appCoroutineScope = appCoroutineScope, analyticsService = analyticsService, @@ -91,11 +91,14 @@ class RustMatrixClientFactory( indexDirectory.deleteRecursively() } + // Use a dedicated delegate for this client, so that an auth error only affects this session + val sessionDelegate = createSessionDelegate() val client = getBaseClientBuilder( sessionPaths = sessionPaths, clientSecret = clientSecret, slidingSyncType = ClientBuilderSlidingSync.Restored, isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, ) .homeserverUrl(sessionData.homeserverUrl) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) @@ -131,22 +134,14 @@ class RustMatrixClientFactory( .withOfflineMode() .finish() - RustMatrixClient( + innerMatrixClientFactory.create( sessionPaths = sessionData.getSessionPaths(), innerClient = client, - sessionStore = sessionStore, - appCoroutineScope = appCoroutineScope, - sessionDelegate = sessionDelegate, innerSyncService = syncService, - dispatchers = coroutineDispatchers, baseCacheDirectory = cacheDirectory, - clock = clock, - timelineEventFilterFactory = timelineEventFilterFactory, - featureFlagService = featureFlagService, - analyticsService = analyticsService, - workManagerScheduler = workManagerScheduler, contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) }, isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, ).also { Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") } @@ -157,6 +152,7 @@ class RustMatrixClientFactory( clientSecret: ClientSecret?, slidingSyncType: ClientBuilderSlidingSync, isMessageSearchAvailable: Boolean, + sessionDelegate: ClientSessionDelegate, ): ClientBuilder { return clientBuilderProvider.provide() .run { @@ -192,7 +188,7 @@ class RustMatrixClientFactory( // use, or left unencrypted for sessions without one, matching those stores. withSearchIndexStore( path = File(sessionPaths.fileDirectory, SEARCH_INDEX_DIRECTORY).absolutePath, - password = clientSecret?.formattedAsString(), + key = clientSecret?.formattedAsString(), ) } else { this diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt new file mode 100644 index 0000000000..57f6f525c9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt @@ -0,0 +1,27 @@ +/* + * 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.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import org.matrix.rustcomponents.sdk.TaskHandle +import java.util.concurrent.atomic.AtomicBoolean + +class RustSdkPendingTask( + private val taskHandle: TaskHandle, +) : SdkPendingTask { + private val closed = AtomicBoolean(false) + + override fun isRunning(): Boolean = !closed.get() && !taskHandle.isFinished() + + override fun close() { + if (!closed.getAndSet(true)) { + taskHandle.cancelAndDestroy() + } + } +} 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 index 98de23c7c3..aaafd9abc6 100644 --- 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 @@ -35,6 +35,7 @@ class RustTemporaryMatrixClientFactory( clientSecret = null, slidingSyncType = ClientBuilderSlidingSync.Native, isMessageSearchAvailable = false, + sessionDelegate = NoOpClientSessionDelegate, ) .serverName(formattedServerName) .build() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt index 8dba525788..3d220e8afd 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt @@ -44,6 +44,9 @@ class DefaultGetDatabaseSizesUseCase( cryptoStore = cryptoSize.bytes, eventCacheStore = eventCacheSize.bytes, mediaStore = mediaCacheSize.bytes, + // TODO - The search index is not stored in a database file but in a directory, which can be expensive to calculate. + // For now, we return null to avoid performance issues. + searchIndex = null, )) } 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 1ae2a16d38..c3644eb959 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 @@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.core.UserId 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.NoOpClientSessionDelegate 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 @@ -344,7 +345,14 @@ class RustMatrixAuthenticationService( it.scan( qrCodeData = qrCodeData.rustQrCodeData, progressListener = progressListener, - ) + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } } // Ensure that the user is not already logged in with the same account ensureNotAlreadyLoggedIn(client) @@ -407,6 +415,7 @@ class RustMatrixAuthenticationService( clientSecret = pendingKey, slidingSyncType = ClientBuilderSlidingSync.Discovered, isMessageSearchAvailable = isMessageSearchAvailable(), + sessionDelegate = NoOpClientSessionDelegate, ) .config() .build() @@ -440,6 +449,7 @@ class RustMatrixAuthenticationService( clientSecret = pendingKey, slidingSyncType = ClientBuilderSlidingSync.Discovered, isMessageSearchAvailable = isMessageSearchAvailable(), + sessionDelegate = NoOpClientSessionDelegate, ) .serverNameOrHomeserverUrl(baseUrlOrServerName) .build() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/AppMatrixBindingContainer.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/AppMatrixBindingContainer.kt new file mode 100644 index 0000000000..3146ad6fbb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/AppMatrixBindingContainer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.di + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +@BindingContainer +@ContributesTo(AppScope::class) +object AppMatrixBindingContainer { + @Provides + fun provideLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt new file mode 100644 index 0000000000..14b59b14e6 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/DefaultElementCallTransportFactory.kt @@ -0,0 +1,38 @@ +/* + * 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.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.matrix.ElementCallMatrixTransport +import io.element.android.call.matrix.ElementCallSdkTransport +import io.element.android.features.callnative.api.ElementCallTransportFactory +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.impl.RustMatrixClient + +/** + * Gives the native call component its view of Matrix. + * + * Here because this is the only module in the app compiled against the Rust SDK, and the component's + * turnkey transport needs a raw SDK client. No feature module ever sees the SDK, the rule that no + * feature depends on `libraries/matrix/impl` still holds, and the SDK version contract is checked where + * the app's own SDK pin lives. + * + * The transport is built on the client's own coroutine scope, which is what ties it to the session: the + * component has no shutdown of its own, so cancelling that scope at logout is the only thing that stops + * the RTC core and its to-device subscription. + */ +@ContributesBinding(AppScope::class) +class DefaultElementCallTransportFactory : ElementCallTransportFactory { + override fun create(client: MatrixClient): ElementCallMatrixTransport { + return ElementCallSdkTransport( + client = (client as RustMatrixClient).innerClient, + sessionScope = client.sessionCoroutineScope, + ) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt index 7ee9c7d0b3..6bbc8887d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt @@ -20,14 +20,14 @@ import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType object RustIdentityResetHandleFactory { fun create( userId: UserId, - identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle? + identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?, ): Result { return runCatchingExceptions { identityResetHandle?.let { when (val authType = identityResetHandle.authType()) { - is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl) + is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl, authType.info.session) // User interactive authentication (user + password) - CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle) + is CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle, authType.info.session) } } } @@ -37,9 +37,12 @@ object RustIdentityResetHandleFactory { class RustPasswordIdentityResetHandle( private val userId: UserId, private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, + override val session: String, ) : IdentityPasswordResetHandle { override suspend fun resetPassword(password: String): Result { - return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) } + return runCatchingExceptions { + identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password, session))) + } } override suspend fun cancel() { @@ -50,9 +53,10 @@ class RustPasswordIdentityResetHandle( class RustIdentityOAuthResetHandle( private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, override val url: String, + override val session: String, ) : IdentityOAuthResetHandle { override suspend fun resetOAuth(): Result { - return runCatchingExceptions { identityResetHandle.reset(null) } + return runCatchingExceptions { identityResetHandle.reset(AuthData.OAuth(session)) } } override suspend fun cancel() { 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 8c9695ae4b..e2b417870e 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 @@ -12,6 +12,7 @@ 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 io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -53,8 +54,15 @@ class RustLinkDesktopHandler( } } } - ) - // We emit Done in case the progress listener was deallocated before scan() sent the Done + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } + // We emit Done in case the progress listener was deallocated before the task sent the Done _linkDesktopStep.emit(LinkDesktopStep.Done) } catch (e: QrCodeDecodeException) { Timber.tag(tag.value).w(e, "Invalid QR code scanned") 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 33f1ad736d..868721ced9 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 @@ -11,6 +11,7 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -48,8 +49,15 @@ class RustLinkMobileHandler( } } } - ) - // We emit Done in case the progress listener was deallocated before generate() sent the Done + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } + // We emit Done in case the progress listener was deallocated before the task sent the Done _linkMobileStep.emit(LinkMobileStep.Done) } catch (e: HumanQrGrantLoginException.Expired) { // Note: the SDK does not return this error, but is returning `.NotFound` instead when the QR code expires. We catch both just in case. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt index 69a76f2825..57f2b8c597 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -38,4 +38,5 @@ internal fun Session.toSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt index 08bef24722..c42e9c1f02 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt @@ -8,10 +8,27 @@ package io.element.android.libraries.matrix.impl.media +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.media.MediaSource +import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource fun RustMediaSource.map(): MediaSource = use { MediaSource(it.url(), it.toJson()) } + +internal fun MediaSource.toRustMediaSource(): RustMediaSource { + val json = this.json + return try { + if (json != null) { + RustMediaSource.fromJson(json) + } else { + RustMediaSource.fromUrl(safeUrl) + } + } catch (e: LinkageError) { + // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded + val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess + if (isTest) RustMediaSource(NoHandle) else throw e + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 7b3a1d9f10..0b8229cb6b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.use import java.io.File -import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource class RustMediaLoader( private val baseCacheDirectory: File, @@ -80,13 +79,4 @@ class RustMediaLoader( } } } - - private fun MediaSource.toRustMediaSource(): RustMediaSource { - val json = this.json - return if (json != null) { - RustMediaSource.fromJson(json) - } else { - RustMediaSource.fromUrl(safeUrl) - } - } } 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 17a3910c98..3be63f4f97 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 @@ -158,6 +158,7 @@ class JoinedRustRoom( inner = innerRoom.threadListService(), contentMapper = TimelineEventContentMapper(), roomCoroutineScope = roomCoroutineScope, + roomDispatcher = roomDispatcher, ) override val syncUpdateFlow = flow { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt index 39cde68c31..4392eb8bee 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.ForwardEventException import io.element.android.libraries.matrix.impl.roomlist.roomOrNull -import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered import kotlinx.coroutines.withTimeout import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.RoomListService @@ -31,33 +30,36 @@ class RoomContentForwarder( private val roomListService: RoomListService, ) { /** - * Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds]. - * @param fromTimeline the room to forward the event from - * @param eventId the id of the event to forward - * @param toRoomIds the ids of the rooms to forward the event to - * @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room + * Forwards the events with the given [eventIds] from the [fromTimeline] to the given [toRoomIds]. + * The events are sent to each room in the order they are provided in [eventIds], it is up to the caller to sort them. + * @param fromTimeline the room to forward the events from + * @param eventIds the ids of the events to forward, in the order they must be sent + * @param toRoomIds the ids of the rooms to forward the events to + * @param timeoutMs the maximum time in milliseconds to wait for an event to be sent to a room */ suspend fun forward( fromTimeline: Timeline, - eventId: EventId, + eventIds: List, toRoomIds: List, timeoutMs: Long = 5000L ) { - val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content - ?: throw ForwardEventException(toRoomIds) - - val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content - ?: throw ForwardEventException(toRoomIds) - - val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } + val contents = eventIds.map { eventId -> + val timelineEvent = fromTimeline.getEventTimelineItemByEventId(eventId.value) + val content = timelineEvent.content as? TimelineItemContent.MsgLike ?: throw ForwardEventException(toRoomIds) + val message = content.content.kind as? MsgLikeKind.Message ?: throw ForwardEventException(toRoomIds) + contentWithoutRelationFromMessage(message.content) + } + val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId) } val failedForwardingTo = mutableSetOf() targetRooms.parallelMap { room -> room.use { targetRoom -> runCatchingExceptions { - // Sending a message requires a registered timeline listener - targetRoom.timeline().runWithTimelineListenerRegistered { - withTimeout(timeoutMs.milliseconds) { - targetRoom.timeline().send(contentWithoutRelationFromMessage(content)) + targetRoom.timeline().use { timeline -> + // Send the contents sequentially, so that they are received in the same order + contents.forEach { content -> + withTimeout(timeoutMs.milliseconds) { + timeline.send(content) + } } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt index 960509b503..6d20a23f26 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -187,11 +187,11 @@ class RustRoomFactory( * Get the Rust room for a room, retrying after the room list is loaded if necessary. */ private suspend fun awaitRoomInRoomList(roomId: RoomId): Room? { - var sdkRoom = innerRoomListService.roomOrNull(roomId.value) + var sdkRoom = innerRoomListService.roomOrNull(roomId) if (sdkRoom == null) { // ... otherwise, lets wait for the SS to load all rooms and check again. roomListService.allRooms.awaitLoaded() - sdkRoom = innerRoomListService.roomOrNull(roomId.value) + sdkRoom = innerRoomListService.roomOrNull(roomId) } if (sdkRoom == null) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt index 1d2f465b64..e75d6dc0da 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt @@ -46,10 +46,13 @@ class RustTimelineEventFilterFactory : TimelineEventFilterFactory { } else { emptyList() } + + // Exclude any custom state events not supported by the SDK + val customStateEventsFilter = listOf(TimelineEventCondition.AnyCustomStateEvent) return if (excludedEventTypes.isNotEmpty() || excludedMembershipChanges.isNotEmpty()) { - TimelineEventFilter.Exclude(excludedEventTypes + excludedMembershipChanges) + TimelineEventFilter.Exclude(customStateEventsFilter + excludedEventTypes + excludedMembershipChanges) } else { - null + TimelineEventFilter.Exclude(customStateEventsFilter) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt index a74c5bc378..98248b183b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.map import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService class RustThreadsListService( private val inner: InnerThreadListService, private val roomCoroutineScope: CoroutineScope, + private val roomDispatcher: CoroutineDispatcher, private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), ) : ThreadsListService { private var itemSubscriptionJob: Job? = null @@ -63,6 +66,8 @@ class RustThreadsListService( updated.apply(diff, contentMapper) items.value = updated } + // Fetching and mapping the items can be costly, do not do it on the main thread + .flowOn(roomDispatcher) .launchIn(roomCoroutineScope) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index bf57e4295b..5aacf89bc2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.roomlist import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import kotlinx.coroutines.channels.Channel @@ -57,7 +58,7 @@ fun RoomListInterface.loadingStateFlow(): Flow = internal fun RoomListInterface.entriesFlow( pageSize: Int, initialFilterKind: RoomListEntriesDynamicFilterKind, - onControllerCreated: (RoomListDynamicEntriesController) -> Unit, + onControllerCreated: suspend (RoomListDynamicEntriesController) -> Unit, ): Flow> = callbackFlow { val listener = object : RoomListEntriesListener { @@ -70,9 +71,13 @@ internal fun RoomListInterface.entriesFlow( listener = listener, ) val controller = result.controller() - controller.setFilter(initialFilterKind) - onControllerCreated(controller) - awaitClose { + // Use try/finally rather than awaitClose's block, so that the resources are released even if + // onControllerCreated is cancelled or throws before awaitClose is reached + try { + controller.setFilter(initialFilterKind) + onControllerCreated(controller) + awaitClose() + } finally { result.entriesStream().cancelAndDestroy() controller.destroy() result.destroy() @@ -105,10 +110,10 @@ internal fun RoomListServiceInterface.syncIndicator(): Flow>(replay = 1, extraBufferCapacity = 1) val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) var innerRoomList: InnerRoomList? = null - var dynamicController: RoomListDynamicEntriesController? = null + val dynamicRoomList = RustDynamicRoomList( + summaries = summariesFlow, + loadingState = loadingStateFlow, + processor = processor, + pageSize = pageSize, + initialFilter = initialFilter, + ) val firstRoomsTransaction = analyticsService.startTransaction("Load first set of rooms", "innerRoomList.entriesFlow") @@ -60,7 +65,7 @@ internal class RoomListFactory( pageSize = pageSize, initialFilterKind = RoomListFilterMapper.toRustFilter(initialFilter), onControllerCreated = { controller -> - dynamicController = controller + dynamicRoomList.onControllerCreated(controller, initialFilter) } ).onEach { update -> if (!firstRoomsTransaction.isFinished()) { @@ -80,13 +85,7 @@ internal class RoomListFactory( }.invokeOnCompletion { innerRoomList?.destroy() } - return RustDynamicRoomList( - summaries = summariesFlow, - loadingState = loadingStateFlow, - processor = processor, - pageSize = pageSize, - dynamicController = { dynamicController } - ) + return dynamicRoomList } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt index afdac3db12..97184a5e22 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessor.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.impl.roomlist +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.MutableSharedFlow @@ -43,7 +44,7 @@ class RoomSummaryListProcessor( suspend fun rebuildRoomSummaries() { updateRoomSummaries(emptyList()) { forEachIndexed { i, summary -> - val result = buildRoomSummaryForIdentifier(summary.roomId.value) + val result = buildRoomSummaryForIdentifier(summary.roomId) if (result != null) { this[i] = result } @@ -103,8 +104,8 @@ class RoomSummaryListProcessor( return entry.use { roomSummaryFactory.create(room = it) } } - private suspend fun buildRoomSummaryForIdentifier(identifier: String): RoomSummary? { - return roomListService.roomOrNull(identifier)?.let { room -> + private suspend fun buildRoomSummaryForIdentifier(roomId: RoomId): RoomSummary? { + return roomListService.roomOrNull(roomId)?.let { room -> buildSummaryForRoomListEntry(room) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt index 0d7b2394b3..5161b3d13a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomList.kt @@ -24,37 +24,58 @@ internal class RustDynamicRoomList( override val loadingState: MutableStateFlow, private val processor: RoomSummaryListProcessor, override val pageSize: Int, - private val dynamicController: () -> RoomListDynamicEntriesController?, + initialFilter: RoomListFilter, private val addPagesCount: Int = DEFAULT_ADD_PAGES_COUNT ) : DynamicRoomList { private val mutex = Mutex() + // The controller is created asynchronously, once the entries of the room list are observed + private var dynamicController: RoomListDynamicEntriesController? = null + + // The last requested filter, so that it can be applied when the controller is created + private var currentFilter: RoomListFilter = initialFilter + + /** + * To be called when the controller is created. The controller already has the initial filter set, so apply the filter + * only if it has been updated in the meantime. + */ + suspend fun onControllerCreated(controller: RoomListDynamicEntriesController, initialFilter: RoomListFilter) { + mutex.withLock { + dynamicController = controller + if (currentFilter != initialFilter) { + controller.applyFilter(currentFilter) + } + } + } + override suspend fun rebuildSummaries() { processor.rebuildRoomSummaries() } override suspend fun updateFilter(filter: RoomListFilter) { mutex.withLock { - dynamicController()?.let { controller -> - // Reset pagination when filter changes - controller.resetToOnePage() - val rustFilter = RoomListFilterMapper.toRustFilter(filter) - controller.setFilter(rustFilter) - // Then preload some pages - controller.addPages(addPagesCount) - } + currentFilter = filter + dynamicController?.applyFilter(filter) } } + private fun RoomListDynamicEntriesController.applyFilter(filter: RoomListFilter) { + // Reset pagination when filter changes + resetToOnePage() + setFilter(RoomListFilterMapper.toRustFilter(filter)) + // Then preload some pages + addPages(addPagesCount) + } + override suspend fun loadMore() { mutex.withLock { - dynamicController()?.addPages(addPagesCount) + dynamicController?.addPages(addPagesCount) } } override suspend fun reset() { mutex.withLock { - dynamicController()?.resetToOnePage() + dynamicController?.resetToOnePage() } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt index c07ca94f26..60ae423f98 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt @@ -12,10 +12,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.media.toRustMediaSource import org.matrix.rustcomponents.sdk.Client -import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner -import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource class RustContentScanner( private val client: Client, @@ -29,18 +28,3 @@ class RustContentScanner( } } } - -private fun MediaSource.toRustMediaSource(): RustMediaSource { - val json = this.json - return try { - if (json != null) { - RustMediaSource.fromJson(json) - } else { - RustMediaSource.fromUrl(safeUrl) - } - } catch (e: LinkageError) { - // Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded - val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess - if (isTest) RustMediaSource(NoHandle) else throw e - } -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt new file mode 100644 index 0000000000..69b3c0fe52 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt @@ -0,0 +1,71 @@ +/* + * 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.search + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.impl.RustSdkPendingTask +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import org.matrix.rustcomponents.sdk.Client +import timber.log.Timber +import java.util.concurrent.atomic.AtomicReference + +@AssistedInject +class RustSearchBackfillService( + @Assisted private val innerClient: Client, + private val searchBackfillRequestBuilderFactory: SearchBackfillRequestBuilder.Factory, + private val workManagerScheduler: WorkManagerScheduler, +) : SearchBackfillService { + @AssistedFactory + interface Factory { + fun create(innerClient: Client): RustSearchBackfillService + } + + private val currentSearchBackfillTaskHandle: AtomicReference = AtomicReference(null) + + override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result = runCatchingExceptions { + val sessionId = SessionId(innerClient.userId()) + if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.SEARCH_BACKFILL)) { + Timber.d("Background search backfill already scheduled for session $sessionId") + return@runCatchingExceptions + } + Timber.d("Scheduling background search backfill for session $sessionId") + val builder = searchBackfillRequestBuilderFactory.create(sessionId) + workManagerScheduler.submit(builder) + } + + override fun cancelPeriodicSearchBackfill() { + val sessionId = SessionId(innerClient.userId()) + Timber.d("Cancelling background search backfill for session $sessionId") + workManagerScheduler.cancel(sessionId, WorkManagerRequestType.SEARCH_BACKFILL) + } + + override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result = runCatchingExceptions { + if (currentSearchBackfillTaskHandle.get()?.isRunning() == true) { + error("Search backfill is already running") + } + RustSdkPendingTask(innerClient.runSearchBackfill(strategy.map())) + .also { currentSearchBackfillTaskHandle.set(it) } + } + + override fun isSearchBackfillRunning(): Boolean { + return currentSearchBackfillTaskHandle.get()?.isRunning() ?: false + } + + override fun cancelSearchBackfill(): Result = runCatchingExceptions { + currentSearchBackfillTaskHandle.getAndSet(null)?.close() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt new file mode 100644 index 0000000000..2b3a1c866a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.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.impl.search + +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import uniffi.matrix_sdk.SearchBackfillStrategy as RustSearchBackfillStrategy + +fun RustSearchBackfillStrategy.map(): SearchBackfillStrategy = when (this) { + RustSearchBackfillStrategy.FOREGROUND -> SearchBackfillStrategy.FOREGROUND + RustSearchBackfillStrategy.BACKGROUND -> SearchBackfillStrategy.BACKGROUND +} + +fun SearchBackfillStrategy.map(): RustSearchBackfillStrategy = when (this) { + SearchBackfillStrategy.FOREGROUND -> RustSearchBackfillStrategy.FOREGROUND + SearchBackfillStrategy.BACKGROUND -> RustSearchBackfillStrategy.BACKGROUND +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt new file mode 100644 index 0000000000..df9feee80b --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.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.search.workmanager + +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.workDataOf +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillWorker.Companion.SESSION_ID_PARAM +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import kotlinx.coroutines.flow.first +import timber.log.Timber +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.hours +import kotlin.time.toJavaDuration + +interface SearchBackfillRequestBuilder : WorkManagerRequestBuilder { + fun interface Factory { + fun create(sessionId: SessionId): SearchBackfillRequestBuilder + } +} + +/** + * Builds a work request that runs the search backfill (back paginations) only when there is network connectivity and the device is charging. + */ +@AssistedInject +class DefaultSearchBackfillRequestBuilder( + @Assisted private val sessionId: SessionId, + private val networkMonitor: NetworkMonitor, +) : SearchBackfillRequestBuilder { + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : SearchBackfillRequestBuilder.Factory { + override fun create(sessionId: SessionId): DefaultSearchBackfillRequestBuilder + } + + override suspend fun build(): Result> { + val tag = workManagerTag(sessionId, WorkManagerRequestType.SEARCH_BACKFILL) + val type = WorkManagerWorkerType.Default + + val networkRequestBuilder = NetworkRequest.Builder() + // Only allow unmetered networks to avoid using mobile data for backfilling. + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + // Some customers use a VPN to connect to their homeserver, so VPN networks must be allowed. + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + + // In an air-gapped environment, validating internet connectivity would fail and the worker would never run. + if (networkMonitor.isInAirGappedEnvironment.first()) { + Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") + networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + val constraints = Constraints.Builder() + .setRequiredNetworkRequest(networkRequestBuilder.build(), NetworkType.UNMETERED) + .setRequiresCharging(true) + .build() + + val request = PeriodicWorkRequestBuilder(1.hours.toJavaDuration()) + .setInputData(workDataOf(SESSION_ID_PARAM to sessionId.value)) + .addTag(tag) + .setConstraints(constraints) + .setBackoffCriteria( + backoffPolicy = BackoffPolicy.LINEAR, + backoffDelay = 5, + timeUnit = TimeUnit.MINUTES, + ) + .build() + + return Result.success(listOf(WorkManagerRequestWrapper(request, type))) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt new file mode 100644 index 0000000000..a188e76439 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt @@ -0,0 +1,97 @@ +/* + * 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.search.workmanager + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.binding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class SearchBackfillWorker( + @Assisted params: WorkerParameters, + @ApplicationContext context: Context, + private val matrixClientProvider: MatrixClientProvider, + private val processLifecycleOwner: LifecycleOwner, +) : CoroutineWorker(context, params) { + companion object { + const val SESSION_ID_PARAM = "session_id" + private val TIMEOUT = 10.minutes + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val POLL_INTERVAL = 5.seconds + } + + override suspend fun doWork(): Result { + val appLifecycle = processLifecycleOwner.lifecycle + if (appLifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Timber.w("App is in foreground, skipping search backfill as it would degrade performance") + return Result.retry() + } + + Timber.d("Starting search backfill. This will run up to $TIMEOUT") + val sessionId = inputData.getString(SESSION_ID_PARAM)?.let(::SessionId) ?: return Result.failure() + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure() + + if (client.searchBackfillService.isSearchBackfillRunning()) { + Timber.d("Search backfill is already running for session $sessionId, nothing to do") + return Result.success() + } + + Timber.d("Starting search backfill for session $sessionId") + val task = client.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.BACKGROUND).getOrElse { + Timber.e(it, "Failed to start search backfill") + return Result.failure() + } + + return task.use { + try { + withTimeout(TIMEOUT) { + while (task.isRunning()) { + delay(POLL_INTERVAL) + + if (task.isRunning() && appLifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Timber.w("App is now in foreground, cancelling search backfill as it would degrade performance") + return@withTimeout + } + } + } + Timber.d("Search backfill finished") + Result.success() + } catch (_: TimeoutCancellationException) { + Timber.d("Search backfill timed out after $TIMEOUT, cancelling it") + // Run again later to continue backfilling. + Result.retry() + } + } + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(SearchBackfillWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt index 4cfcf1a95e..394e918903 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt @@ -7,11 +7,13 @@ package io.element.android.libraries.matrix.impl.storage +import android.util.Base64 import io.element.android.libraries.androidutils.crypto.ClientSecret import io.element.android.libraries.core.data.ByteUnit import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.matrix.api.paths.SessionPaths import org.matrix.rustcomponents.sdk.ClientBuilder +import uniffi.matrix_sdk_sqlite.Base64Variant import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder /** @@ -40,10 +42,15 @@ class RustSqliteStoreBuilder( override fun secret(clientSecret: ClientSecret?): SqliteStoreBuilder { when (clientSecret) { null -> Unit - is ClientSecret.Passphrase -> inner = inner.passphrase(clientSecret.value) + is ClientSecret.Passphrase -> { + // Use high entropy passphrase to migrate a passphrase to a raw key. + val base64Passphrase = Base64.decode(clientSecret.value, Base64.NO_WRAP or Base64.NO_PADDING) + inner = inner.highEntropyPassphrase(base64Passphrase, Base64Variant.UNPADDED) + // Once the passphrase is migrated, we can derive a raw key from it and use that for the SDK, replacing the line above. +// inner = inner.key(base64Passphrase) + } is ClientSecret.RawKey -> { - // Ensure the key is 32 bytes long, as required by the SDK - inner = inner.key(clientSecret.keyOfSize(32)) + inner = inner.key(clientSecret.bytes) } } return this @@ -53,15 +60,3 @@ class RustSqliteStoreBuilder( return clientBuilder.sqliteStore(this.inner) } } - -private fun ClientSecret.RawKey.keyOfSize(size: Int): ByteArray { - return if (bytes.size == 32) { - bytes - } else if (bytes.size < 32) { - // If the key is shorter than 32 bytes, pad it with zeros - bytes + ByteArray(32 - bytes.size) - } else { - // Otherwise, take the first 32 bytes of the key - bytes.copyOfRange(0, 32) - } -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt index f7732cd9b7..d08e561fb5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.impl.roomlist.stateFlow import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.SyncServiceState import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean @@ -78,4 +82,10 @@ class RustSyncService( .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + + // TODO actually use a heartbeat mechanism from the SDK, not simulate it with the room list service + override val heartBeat: Flow = inner.roomListService() + .stateFlow() + .filter { it == RoomListServiceState.RUNNING } + .map {} } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt index c932230217..9766da8235 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RoomTimelineExtensions.kt @@ -53,16 +53,3 @@ internal fun TimelineInterface.timelineDiffFlow(): Flow> = }.catch { Timber.d(it, "timelineDiffFlow() failed") }.buffer(Channel.UNLIMITED) - -internal suspend fun TimelineInterface.runWithTimelineListenerRegistered(action: suspend () -> Unit) { - val result = addListener(NoOpTimelineListener) - try { - action() - } finally { - result.cancelAndDestroy() - } -} - -private object NoOpTimelineListener : TimelineListener { - override fun onUpdate(diff: List) = Unit -} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index c6a737e156..d875ac0429 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.TimelineException +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl @@ -489,9 +490,9 @@ class RustTimeline( } } - override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = withContext(dispatcher) { + override suspend fun forwardEvents(eventIds: List, roomIds: List): Result = withContext(dispatcher) { runCatchingExceptions { - roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds) + roomContentForwarder.forward(fromTimeline = inner, eventIds = eventIds, toRoomIds = roomIds) }.onFailure { Timber.tag(loggerTag).e(it) } @@ -697,6 +698,18 @@ class RustTimeline( } } + override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.retrySend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map()) + } + } + + override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result = withContext(dispatcher) { + runCatchingExceptions { + inner.abortSend(eventOrTransactionId.toRustEventOrTransactionId(), sendTarget.map()) + } + } + private suspend fun fetchDetailsForEvent(eventId: EventId): Result = withContext(dispatcher) { runCatchingExceptions { inner.fetchDetailsForEvent(eventId.value) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.kt new file mode 100644 index 0000000000..889735cad9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/SendTarget.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.impl.timeline + +import io.element.android.libraries.matrix.api.timeline.item.SendTarget + +fun SendTarget.map() = when (this) { + is SendTarget.Event -> org.matrix.rustcomponents.sdk.SendTarget.Event + is SendTarget.Edit -> org.matrix.rustcomponents.sdk.SendTarget.Edit + is SendTarget.Redaction -> org.matrix.rustcomponents.sdk.SendTarget.Redaction + is SendTarget.Reaction -> org.matrix.rustcomponents.sdk.SendTarget.Reaction(key) +} 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 db8f4753ec..3225dffc1c 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 @@ -51,6 +51,8 @@ class EventTimelineItemMapper( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState?.map(), + editSendState = editSendState?.map(), + redactionSendState = redactionSendState?.map(), reactions = reactions.map(), receipts = readReceipts.map(), sender = UserId(sender), @@ -113,11 +115,7 @@ fun RustEventSendState?.map(): LocalEventSendState? { ) } is QueueWedgeError.GenericApiError -> { - if (isRecoverable) { - LocalEventSendState.Sending.Event - } else { - LocalEventSendState.Failed.Unknown(queueWedgeError.msg) - } + LocalEventSendState.Failed.Unknown(queueWedgeError.msg) } is QueueWedgeError.InvalidMimeType -> { LocalEventSendState.Failed.InvalidMimeType(queueWedgeError.mimeType) @@ -125,6 +123,10 @@ fun RustEventSendState?.map(): LocalEventSendState? { is QueueWedgeError.MissingMediaContent -> { LocalEventSendState.Failed.MissingMediaContent } + is QueueWedgeError.ExpiredAccessToken -> { + // This will automatically recover, so map it to the sending state + LocalEventSendState.Sending.Event + } } } is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId)) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index 760937c567..a96d5ec214 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -245,6 +245,7 @@ class RustSessionVerificationService( fun destroy() { Timber.d("Destroying RustSessionVerificationService") + listener = null verificationStateListenerTaskHandle.cancelAndDestroy() recoveryStateListenerTaskHandle.cancelAndDestroy() if (this::verificationController.isInitialized) { diff --git a/libraries/matrix/impl/src/main/kotlin/org/matrix/rustsdk/Android.kt b/libraries/matrix/impl/src/main/kotlin/org/matrix/rustsdk/Android.kt new file mode 100644 index 0000000000..8e4b03e1da --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/org/matrix/rustsdk/Android.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package org.matrix.rustsdk + +import android.content.Context + +/** + * This class is used to initialize the Rust SDK on Android and give it access to an Android [Context]. + */ +object Android { + init { + System.loadLibrary("matrix_sdk_ffi") + } + + @JvmStatic + external fun init(context: Context) +} 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 b559b7d4d0..d5d8c62515 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 @@ -12,8 +12,14 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.ContentScanner import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider @@ -21,38 +27,87 @@ 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.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper 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.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientDelegate import java.io.File +import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +@OptIn(ExperimentalCoroutinesApi::class) class RustMatrixClientFactoryTest { @Test fun test() = runTest { - val scheduleVacuumLambda = lambdaRecorder {} - val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) + val scheduleWorkersLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleWorkersLambda, cancelLambda = { _, _ -> }) val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) val result = sut.create(aSessionData()) assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org")) - scheduleVacuumLambda.assertions().isCalledOnce() + runCurrent() + // 2 workers are scheduled: one for the search backfill and one for the vacuum + scheduleWorkersLambda.assertions().isCalledExactly(2) result.destroy() } + + @Test + fun `an auth error on a client only invalidates its own session`() = runTest { + val aliceSessionId = "@alice:server.org" + val bobSessionId = "@bob:server.org" + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = aliceSessionId, isTokenValid = true), + aSessionData(sessionId = bobSessionId, isTokenValid = true), + ), + updateUserProfileResult = { _, _, _, _ -> }, + ) + val userIds = ArrayDeque(listOf(aliceSessionId, bobSessionId)) + val delegates = mutableMapOf() + val sut = createRustMatrixClientFactory( + sessionStore = sessionStore, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), + clientBuilderProvider = FakeClientBuilderProvider { + val userId = userIds.removeFirst() + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + userId = userId, + withUtdHook = {}, + setDelegateResult = { delegates[userId] = it }, + ) + } + ) + }, + ) + val aliceClient = sut.create(sessionStore.getSession(aliceSessionId)!!) + val bobClient = sut.create(sessionStore.getSession(bobSessionId)!!) + + delegates.getValue(aliceSessionId)!!.didReceiveAuthError(isSoftLogout = false) + + assertThat(sessionStore.getSession(aliceSessionId)!!.isTokenValid).isFalse() + assertThat(sessionStore.getSession(bobSessionId)!!.isTokenValid).isTrue() + aliceClient.destroy() + bobClient.destroy() + } } fun TestScope.createRustMatrixClientFactory( cacheDirectory: File = File("/cache"), sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }), sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, @@ -61,12 +116,52 @@ fun TestScope.createRustMatrixClientFactory( sessionStore = sessionStore, userAgentProvider = SimpleUserAgentProvider(), proxyProvider = FakeProxyProvider(), - clock = FakeSystemClock(), analyticsService = FakeAnalyticsService(), featureFlagService = FakeFeatureFlagService(), - timelineEventFilterFactory = FakeTimelineEventFilterFactory(), clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, - workManagerScheduler = workManagerScheduler, clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), + innerMatrixClientFactory = createRustMatrixClientInnerFactory(sessionStore, workManagerScheduler), ) + +private fun TestScope.createRustMatrixClientInnerFactory( + sessionStore: SessionStore, + workManagerScheduler: FakeWorkManagerScheduler, +) = object : RustMatrixClient.Factory { + override fun create( + sessionPaths: SessionPaths, + innerClient: Client, + innerSyncService: ClientSyncService, + baseCacheDirectory: File, + contentScanner: ContentScanner?, + isMessageSearchAvailable: Boolean, + sessionDelegate: RustClientSessionDelegate, + ) = RustMatrixClient( + sessionPaths = sessionPaths, + innerClient = innerClient, + innerSyncService = innerSyncService, + baseCacheDirectory = baseCacheDirectory, + contentScanner = contentScanner, + isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, + sessionStore = sessionStore, + appCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + clock = FakeSystemClock(), + timelineEventFilterFactory = FakeTimelineEventFilterFactory(), + featureFlagService = FakeFeatureFlagService(), + analyticsService = FakeAnalyticsService(), + workManagerScheduler = workManagerScheduler, + searchBackfillServiceFactory = object : RustSearchBackfillService.Factory { + override fun create(innerClient: Client) = RustSearchBackfillService( + innerClient = innerClient, + searchBackfillRequestBuilderFactory = { + object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + }, + workManagerScheduler = workManagerScheduler, + ) + }, + ) +} 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 ea399b39df..66c5ac6e15 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 @@ -14,9 +14,12 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.data.bytes import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -26,9 +29,11 @@ import io.element.android.libraries.matrix.test.scanner.FakeContentScanner 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.libraries.workmanager.api.WorkManagerRequestWrapper 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.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers @@ -39,9 +44,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.CreateRoomParameters +import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.RoomHistoryVisibility 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" @@ -76,7 +81,8 @@ class RustMatrixClientTest { @Test fun `retrieving the UserProfile updates the database`() = runTest { val profilePersisted = CompletableDeferred() - val updateUserProfileResult = lambdaRecorder { _, _, _ -> profilePersisted.complete(Unit) } + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } val sessionStore = InMemorySessionStore( initialList = listOf( aSessionData( @@ -89,24 +95,160 @@ class RustMatrixClientTest { ) val client = createRustMatrixClient( client = FakeFfiClient( - getProfileResult = { userId -> - UserProfile( - userId = userId, - displayName = A_USER_NAME, - avatarUrl = AN_AVATAR_URL, - status = null, - call = null, - ) - }, + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, ), sessionStore = sessionStore, ) profilePersisted.await() + getMediaThumbnailResult.assertions().isCalledOnce() + .with(any(), value(240uL), value(240uL)) updateUserProfileResult.assertions().isCalledOnce() .with( value(A_USER_ID.value), value(A_USER_NAME), value(AN_AVATAR_URL), + value("AQID"), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile does not download the avatar again if it has not changed`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = "storedData", + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isNeverCalled() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + value("storedData"), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile downloads the avatar again if the previous download failed`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = null, + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isCalledOnce() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + value("AQID"), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile clears the avatar data if the avatar download fails`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> error("Download failed") } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = "mxc://server.org/previousAvatar", + userAvatarData = "previousData", + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isCalledOnce() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(AN_AVATAR_URL), + value(null), + ) + client.destroy() + } + + @Test + fun `retrieving the UserProfile without avatar clears the avatar data`() = runTest { + val profilePersisted = CompletableDeferred() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> byteArrayOf(1, 2, 3) } + val sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + userAvatarData = "storedData", + ) + ), + updateUserProfileResult = updateUserProfileResult, + ) + val client = createRustMatrixClient( + client = FakeFfiClient( + getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = null) }, + getMediaThumbnailResult = getMediaThumbnailResult, + ), + sessionStore = sessionStore, + ) + profilePersisted.await() + getMediaThumbnailResult.assertions().isNeverCalled() + updateUserProfileResult.assertions().isCalledOnce() + .with( + value(A_USER_ID.value), + value(A_USER_NAME), + value(null), + value(null), ) client.destroy() } @@ -114,7 +256,15 @@ class RustMatrixClientTest { @Test fun `getDatabaseSizes returns the database sizes`() = runTest { val client = createRustMatrixClient( - client = FakeFfiClient(getStoreSizesResult = { StoreSizes(null, 10uL, 11uL, 12uL) }) + client = FakeFfiClient(getStoreSizesResult = { + StoreSizes( + cryptoStore = null, + stateStore = 10uL, + eventCacheStore = 11uL, + mediaStore = 12uL, + searchIndex = 13uL, + ) + }) ) client.getDatabaseSizes().getOrThrow().run { @@ -122,6 +272,7 @@ class RustMatrixClientTest { assertThat(stateStore).isEqualTo(10.bytes) assertThat(eventCacheStore).isEqualTo(11.bytes) assertThat(mediaStore).isEqualTo(12.bytes) + assertThat(searchIndex).isEqualTo(13.bytes) } } @@ -169,7 +320,7 @@ class RustMatrixClientTest { private fun TestScope.createRustMatrixClient( client: Client = FakeFfiClient(), sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) = RustMatrixClient( innerClient = client, @@ -186,8 +337,19 @@ class RustMatrixClientTest { timelineEventFilterFactory = FakeTimelineEventFilterFactory(), featureFlagService = FakeFeatureFlagService(), analyticsService = FakeAnalyticsService(), - workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), contentScanner = FakeContentScanner(), isMessageSearchAvailable = false, + searchBackfillServiceFactory = object : RustSearchBackfillService.Factory { + override fun create(innerClient: Client) = RustSearchBackfillService( + innerClient = innerClient, + searchBackfillRequestBuilderFactory = { + object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + }, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), + ) + }, ) } 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 index f18f633889..4fb3250071 100644 --- 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 @@ -8,19 +8,7 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -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 @@ -56,28 +44,4 @@ class RustTemporaryMatrixClientFactoryTest { rustMatrixClientFactory = rustMatrixClientFactory, ) } - - private fun TestScope.createRustMatrixClientFactory( - cacheDirectory: File = File("/cache"), - sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, - ), - clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - ) = 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, - clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), - ) } 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 d81c6021da..95047ea452 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 @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.libraries.matrix.impl.auth import com.google.common.truth.Truth.assertThat @@ -36,7 +38,11 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -171,6 +177,39 @@ class RustMatrixAuthenticationServiceTest { closeResult.assertions().isCalledOnce() } + @Test + fun `loginWithQrCode cancels the login task when it is cancelled`() = runTest { + val cancelResult = lambdaRecorder {} + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + newLoginWithQrCodeHandlerResult = { + FakeFfiLoginWithQrCodeHandler( + scanResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + }, + closeResult = {}, + ) + }, + ) + }, + ), + ) + + val job = backgroundScope.launch { + sut.loginWithQrCode(aSdkQrCodeLoginData()) {} + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData( FakeFfiQrCodeData( baseUrlResult = { A_HOMESERVER_URL }, @@ -255,7 +294,7 @@ class RustMatrixAuthenticationServiceTest { } private fun TestScope.createRustMatrixAuthenticationService( - sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }), + sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _, _ -> }), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), enterpriseService: EnterpriseService = FakeEnterpriseService(), sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")), @@ -265,7 +304,7 @@ class RustMatrixAuthenticationServiceTest { cacheDirectory = File("/cache"), sessionStore = sessionStore, clientBuilderProvider = clientBuilderProvider, - workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, ) return RustMatrixAuthenticationService( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt index 68990560ce..d0e9592948 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt @@ -40,6 +40,8 @@ internal fun aRustEventTimelineItem( canBeRepliedTo: Boolean = true, reactions: List = emptyList(), lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(), + editSendState: EventSendState? = null, + redactionSendState: EventSendState? = null, ) = EventTimelineItem( isRemote = isRemote, eventOrTransactionId = eventOrTransactionId, @@ -59,4 +61,6 @@ internal fun aRustEventTimelineItem( canBeRepliedTo = canBeRepliedTo, reactions = reactions, lazyProvider = lazyProvider, + editSendState = editSendState, + redactionSendState = redactionSendState, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt index 947e6e7aa1..07997e9109 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt @@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.UserStatus internal fun aRustUserProfile( userId: String = A_USER_ID.value, displayName: String = "displayName", - avatarUrl: String = "avatarUrl", + avatarUrl: String? = "avatarUrl", status: UserStatus? = null, call: UserCall? = null, ) = UserProfile( 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 8c6411197a..9d6d8dd63a 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.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.MediaSource import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationProcessSetup @@ -44,6 +45,7 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate import org.matrix.rustcomponents.sdk.UserProfile +import uniffi.matrix_sdk.SearchBackfillStrategy import uniffi.matrix_sdk_base.MediaRetentionPolicy class FakeFfiClient( @@ -70,8 +72,11 @@ class FakeFfiClient( private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, private val getUrlResult: (String) -> ByteArray = { lambdaError() }, + private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() }, private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() }, + private val runSearchBackfillResult: (SearchBackfillStrategy) -> TaskHandle = { lambdaError() }, private val closeResult: () -> Unit = {}, + private val setDelegateResult: (ClientDelegate?) -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId override fun deviceId(): String = deviceId @@ -81,7 +86,10 @@ class FakeFfiClient( override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings override fun encryption(): Encryption = encryption override fun session(): Session = session - override fun setDelegate(delegate: ClientDelegate?): TaskHandle = FakeFfiTaskHandle() + override fun setDelegate(delegate: ClientDelegate?): TaskHandle { + setDelegateResult(delegate) + return FakeFfiTaskHandle() + } override suspend fun cachedAvatarUrl(): String? = null override suspend fun restoreSession(session: Session) = Unit override fun syncService(): SyncServiceBuilder = FakeFfiSyncServiceBuilder() @@ -122,6 +130,10 @@ class FakeFfiClient( return getProfileResult(userId) } + override suspend fun getMediaThumbnail(mediaSource: MediaSource, width: ULong, height: ULong): ByteArray { + return getMediaThumbnailResult(mediaSource, width, height) + } + override suspend fun homeserverLoginDetails(): HomeserverLoginDetails { return homeserverLoginDetailsResult() } @@ -164,5 +176,7 @@ class FakeFfiClient( contentScannerResult() } + override fun runSearchBackfill(strategy: SearchBackfillStrategy): TaskHandle = runSearchBackfillResult(strategy) + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt index 99caa2f4ed..4dfa4ec385 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClientBuilder.kt @@ -50,5 +50,6 @@ class FakeFfiClientBuilder( override fun inMemoryStore(): ClientBuilder = this override fun crossProcessLockConfig(crossProcessLockConfig: CrossProcessLockConfig): ClientBuilder = this override fun dmRoomDefinition(dmRoomDefinition: DmRoomDefinition): ClientBuilder = this + override fun withSearchIndexStore(path: String, password: String?): ClientBuilder = this override suspend fun build() = buildResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt index 0899b16325..c205010ba4 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeTask import org.matrix.rustcomponents.sdk.GrantQrLoginProgress import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener import org.matrix.rustcomponents.sdk.NoHandle @@ -18,21 +19,28 @@ import org.matrix.rustcomponents.sdk.QrCodeData class FakeFfiGrantLoginWithQrCodeHandler( private val generateResult: suspend () -> Unit = {}, private val scanResult: suspend (QrCodeData) -> Unit = {}, + private val cancelResult: () -> Unit = {}, ) : GrantLoginWithQrCodeHandler(NoHandle) { private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null private var scanProgressListener: GrantQrLoginProgressListener? = null - override suspend fun generate(progressListener: GrantGeneratedQrLoginProgressListener) { + override fun generate(progressListener: GrantGeneratedQrLoginProgressListener): GrantLoginWithQrCodeTask { generateProgressListener = progressListener - generateResult() + return FakeFfiGrantLoginWithQrCodeTask( + waitResult = generateResult, + cancelResult = cancelResult, + ) } fun emitGenerateProgress(progress: GrantGeneratedQrLoginProgress) { generateProgressListener?.onUpdate(progress) } - override suspend fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener) { + override fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener): GrantLoginWithQrCodeTask { scanProgressListener = progressListener - scanResult(qrCodeData) + return FakeFfiGrantLoginWithQrCodeTask( + waitResult = { scanResult(qrCodeData) }, + cancelResult = cancelResult, + ) } fun emitScanProgress(progress: GrantQrLoginProgress) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt new file mode 100644 index 0000000000..016267365a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.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.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeTask +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiGrantLoginWithQrCodeTask( + private val waitResult: suspend () -> Unit = {}, + private val cancelResult: () -> Unit = {}, +) : GrantLoginWithQrCodeTask(NoHandle) { + override suspend fun wait() = waitResult() + override fun cancel() = cancelResult() + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt index 6e6b750643..457b080154 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.GeneratedQrLoginProgressListener import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.LoginWithQrCodeTask import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.QrCodeData import org.matrix.rustcomponents.sdk.QrLoginProgress @@ -17,16 +18,23 @@ import org.matrix.rustcomponents.sdk.QrLoginProgressListener class FakeFfiLoginWithQrCodeHandler( private val generateResult: suspend () -> Unit = {}, private val scanResult: suspend (QrCodeData) -> Unit = {}, + private val cancelResult: () -> Unit = {}, ) : LoginWithQrCodeHandler(NoHandle) { private var scanProgressListener: QrLoginProgressListener? = null - override suspend fun generate(progressListener: GeneratedQrLoginProgressListener) { - generateResult() + override fun generate(progressListener: GeneratedQrLoginProgressListener): LoginWithQrCodeTask { + return FakeFfiLoginWithQrCodeTask( + waitResult = generateResult, + cancelResult = cancelResult, + ) } - override suspend fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener) { + override fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener): LoginWithQrCodeTask { scanProgressListener = progressListener - scanResult(qrCodeData) + return FakeFfiLoginWithQrCodeTask( + waitResult = { scanResult(qrCodeData) }, + cancelResult = cancelResult, + ) } fun emitScanProgress(progress: QrLoginProgress) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt new file mode 100644 index 0000000000..c3fe5e930a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.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.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.LoginWithQrCodeTask +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiLoginWithQrCodeTask( + private val waitResult: suspend () -> Unit = {}, + private val cancelResult: () -> Unit = {}, +) : LoginWithQrCodeTask(NoHandle) { + override suspend fun wait() = waitResult() + override fun cancel() = cancelResult() + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.kt new file mode 100644 index 0000000000..9a80841069 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomListDynamicEntriesController.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.fixtures.fakes + +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.RoomListDynamicEntriesController +import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind + +class FakeFfiRoomListDynamicEntriesController : RoomListDynamicEntriesController(NoHandle) { + val filters = mutableListOf() + + override fun setFilter(kind: RoomListEntriesDynamicFilterKind): Boolean { + filters.add(kind) + return true + } + + override fun resetToOnePage() = Unit + + override fun addOnePage() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt index 24d7884547..782bebd295 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt @@ -11,7 +11,11 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.TaskHandle -class FakeFfiTaskHandle : TaskHandle(NoHandle) { - override fun cancel() = Unit +class FakeFfiTaskHandle( + private val isFinishedResult: () -> Boolean = { false }, + private val cancelResult: () -> Unit = {}, +) : TaskHandle(NoHandle) { + override fun isFinished(): Boolean = isFinishedResult() + override fun cancel() = cancelResult() override fun destroy() = Unit } 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 8a0a046946..7c8bd09416 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 @@ -17,9 +17,11 @@ 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.test.QR_CODE_DATA import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -140,6 +142,26 @@ class RustLinkDesktopHandlerTest { } } + @Test + fun `when handleScannedQrCode is cancelled, the task is cancelled`() = runTest { + val cancelResult = lambdaRecorder { } + val handler = FakeFfiGrantLoginWithQrCodeHandler( + scanResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + val sut = createRustLinkDesktopHandler( + handler, + ) + val job = backgroundScope.launch { + sut.handleScannedQrCode(QR_CODE_DATA) + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun TestScope.createRustLinkDesktopHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkDesktopHandler( 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 f1f568e3ca..912c79c0ac 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 @@ -19,9 +19,11 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLogin 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.lambda.lambdaRecorder import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -176,6 +178,26 @@ class RustLinkMobileHandlerTest { } } + @Test + fun `when start is cancelled, the task is cancelled`() = runTest { + val cancelResult = lambdaRecorder { } + val handler = FakeFfiGrantLoginWithQrCodeHandler( + generateResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + val sut = createRustLinkMobileHandler( + handler, + ) + val job = backgroundScope.launch { + sut.start() + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun TestScope.createRustLinkMobileHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkMobileHandler( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt index 0fc9ad0603..fd9f311b95 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -122,6 +123,7 @@ class RustThreadsListServiceTest { ) = RustThreadsListService( inner = inner, roomCoroutineScope = backgroundScope, + roomDispatcher = StandardTestDispatcher(testScheduler), ) private fun aRustThreadListUpdate() = ThreadListUpdate.Append( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt new file mode 100644 index 0000000000..fc2686f882 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustDynamicRoomListTest.kt @@ -0,0 +1,67 @@ +/* + * 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.roomlist + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListDynamicEntriesController +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService +import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RustDynamicRoomListTest { + @Test + fun `updateFilter before the controller is created applies the filter when it is created`() = runTest { + val sut = createRustDynamicRoomList() + sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query")) + val controller = FakeFfiRoomListDynamicEntriesController() + sut.onControllerCreated(controller, initialFilter = RoomListFilter.all()) + assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query"))) + } + + @Test + fun `when the filter is not updated, the controller filter is not changed when it is created`() = runTest { + val sut = createRustDynamicRoomList() + val controller = FakeFfiRoomListDynamicEntriesController() + sut.onControllerCreated(controller, initialFilter = RoomListFilter.all()) + assertThat(controller.filters).isEmpty() + } + + @Test + fun `updateFilter after the controller is created applies the filter`() = runTest { + val sut = createRustDynamicRoomList() + val controller = FakeFfiRoomListDynamicEntriesController() + sut.onControllerCreated(controller, initialFilter = RoomListFilter.all()) + sut.updateFilter(RoomListFilter.NormalizedMatchRoomName("query")) + assertThat(controller.filters).containsExactly(RoomListFilterMapper.toRustFilter(RoomListFilter.NormalizedMatchRoomName("query"))) + } + + private fun TestScope.createRustDynamicRoomList(): RustDynamicRoomList { + val summaries = MutableSharedFlow>(replay = 1) + return RustDynamicRoomList( + summaries = summaries, + loadingState = MutableStateFlow(RoomList.LoadingState.NotLoaded), + processor = RoomSummaryListProcessor( + summaries, + FakeFfiRoomListService(), + coroutineContext = StandardTestDispatcher(testScheduler), + roomSummaryFactory = RoomSummaryFactory(), + analyticsService = FakeAnalyticsService(), + ), + pageSize = 10, + initialFilter = RoomListFilter.all(), + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt new file mode 100644 index 0000000000..aeac7a506c --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt @@ -0,0 +1,198 @@ +/* + * 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.search + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +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.TaskHandle +import uniffi.matrix_sdk.SearchBackfillStrategy as RustSearchBackfillStrategy + +class RustSearchBackfillServiceTest { + @Test + fun `schedulePeriodicSearchBackfill submits a request when nothing is pending`() = runTest { + val builder = aRequestBuilder() + val createBuilderLambda = lambdaRecorder { builder } + val submitLambda = lambdaRecorder { } + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory(createBuilderLambda), + workManagerScheduler = FakeWorkManagerScheduler( + submitLambda = submitLambda, + hasPendingWorkLambda = { _, _ -> false }, + ), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.isSuccess).isTrue() + createBuilderLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + submitLambda.assertions().isCalledOnce().with(value(builder)) + } + + @Test + fun `schedulePeriodicSearchBackfill does nothing when work is already pending`() = runTest { + val hasPendingWorkLambda = lambdaRecorder { _, _ -> true } + val createBuilderLambda = lambdaRecorder { error("Should not be called") } + val submitLambda = lambdaRecorder { error("Should not be called") } + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory(createBuilderLambda), + workManagerScheduler = FakeWorkManagerScheduler( + submitLambda = submitLambda, + hasPendingWorkLambda = hasPendingWorkLambda, + ), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.isSuccess).isTrue() + hasPendingWorkLambda.assertions().isCalledOnce().with(value(A_SESSION_ID), value(WorkManagerRequestType.SEARCH_BACKFILL)) + createBuilderLambda.assertions().isNeverCalled() + submitLambda.assertions().isNeverCalled() + } + + @Test + fun `schedulePeriodicSearchBackfill returns a failure when submitting fails`() = runTest { + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory { aRequestBuilder() }, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = { throw IllegalStateException("boom") }), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `cancelPeriodicSearchBackfill cancels the search backfill work of the session`() { + val cancelLambda = lambdaRecorder { _, _ -> } + val service = createService(workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelLambda)) + + service.cancelPeriodicSearchBackfill() + + cancelLambda.assertions().isCalledOnce().with(value(A_SESSION_ID), value(WorkManagerRequestType.SEARCH_BACKFILL)) + } + + @Test + fun `startSearchBackfill maps the strategy and returns a running task`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { false }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + val task = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrThrow() + + strategyLambda.assertions().isCalledOnce().with(value(RustSearchBackfillStrategy.FOREGROUND)) + assertThat(task.isRunning()).isTrue() + assertThat(service.isSearchBackfillRunning()).isTrue() + } + + @Test + fun `startSearchBackfill maps the background strategy`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle() } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + service.startSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + strategyLambda.assertions().isCalledOnce().with(value(RustSearchBackfillStrategy.BACKGROUND)) + } + + @Test + fun `startSearchBackfill fails when a backfill is already running`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { false }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + val result = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + strategyLambda.assertions().isCalledOnce() + } + + @Test + fun `startSearchBackfill can start again once the previous task has finished`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { true }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + assertThat(service.isSearchBackfillRunning()).isFalse() + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + + strategyLambda.assertions().isCalledExactly(2) + } + + @Test + fun `startSearchBackfill returns a failure when the SDK fails`() { + val service = createService(client = FakeFfiClient(runSearchBackfillResult = { error("boom") })) + + val result = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + assertThat(result.isFailure).isTrue() + assertThat(service.isSearchBackfillRunning()).isFalse() + } + + @Test + fun `isSearchBackfillRunning is false when nothing was started`() { + assertThat(createService().isSearchBackfillRunning()).isFalse() + } + + @Test + fun `cancelSearchBackfill cancels the running task`() { + val cancelLambda = lambdaRecorder { } + val service = createService( + client = FakeFfiClient(runSearchBackfillResult = { FakeFfiTaskHandle(cancelResult = cancelLambda) }), + ) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + val result = service.cancelSearchBackfill() + + assertThat(result.isSuccess).isTrue() + cancelLambda.assertions().isCalledOnce() + assertThat(service.isSearchBackfillRunning()).isFalse() + } + + @Test + fun `cancelSearchBackfill without a running task is a no-op`() { + assertThat(createService().cancelSearchBackfill().isSuccess).isTrue() + } + + @Test + fun `startSearchBackfill works again after cancelSearchBackfill`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle() } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + service.cancelSearchBackfill() + + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + + strategyLambda.assertions().isCalledExactly(2) + } + + private fun aRequestBuilder() = object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + + private fun createService( + client: FakeFfiClient = FakeFfiClient(), + requestBuilderFactory: SearchBackfillRequestBuilder.Factory = SearchBackfillRequestBuilder.Factory { aRequestBuilder() }, + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + ) = RustSearchBackfillService( + innerClient = client, + searchBackfillRequestBuilderFactory = requestBuilderFactory, + workManagerScheduler = workManagerScheduler, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt new file mode 100644 index 0000000000..c16063ada6 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt @@ -0,0 +1,80 @@ +/* + * 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.search.workmanager + +import android.net.NetworkCapabilities +import androidx.work.BackoffPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class DefaultSearchBackfillRequestBuilderTest : RobolectricTest() { + private val expectedTag = workManagerTag(A_SESSION_ID, WorkManagerRequestType.SEARCH_BACKFILL) + + @Test + fun `build - creates a periodic request with session id and tag`() = runTest { + val result = createBuilder().build().getOrThrow().single() + + assertThat(result.type).isEqualTo(WorkManagerWorkerType.Default) + result.request.run { + assertThat(this).isInstanceOf(PeriodicWorkRequest::class.java) + assertThat(workSpec.isPeriodic).isTrue() + assertThat(workSpec.intervalDuration).isEqualTo(1.hours.inWholeMilliseconds) + assertThat(workSpec.input.hasKeyWithValueOfType(SearchBackfillWorker.SESSION_ID_PARAM)).isTrue() + assertThat(workSpec.input.getString(SearchBackfillWorker.SESSION_ID_PARAM)).isEqualTo(A_SESSION_ID.value) + assertThat(workSpec.backoffPolicy).isEqualTo(BackoffPolicy.LINEAR) + assertThat(workSpec.backoffDelayDuration).isEqualTo(5.minutes.inWholeMilliseconds) + assertThat(tags).contains(expectedTag) + assertThat(workSpec.workerClassName).isEqualTo(SearchBackfillWorker::class.java.name) + } + } + + @Test + fun `build - requires charging and an unmetered network`() = runTest { + val result = createBuilder().build().getOrThrow().single() + + val constraints = result.request.workSpec.constraints + assertThat(constraints.requiresCharging()).isTrue() + val networkRequest = constraints.requiredNetworkRequest + assertThat(networkRequest).isNotNull() + assertThat(networkRequest!!.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).isTrue() + } + + @Test + fun `build - validates the network when not in an air-gapped env`() = runTest { + val result = createBuilder(isInAirGapEnvironment = false).build().getOrThrow().single() + + val networkRequest = result.request.workSpec.constraints.requiredNetworkRequest!! + assertThat(networkRequest.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isTrue() + } + + @Test + fun `build - does not validate the network when in an air-gapped env`() = runTest { + val result = createBuilder(isInAirGapEnvironment = true).build().getOrThrow().single() + + val networkRequest = result.request.workSpec.constraints.requiredNetworkRequest!! + assertThat(networkRequest.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse() + } + + private fun createBuilder( + isInAirGapEnvironment: Boolean = false, + ) = DefaultSearchBackfillRequestBuilder( + sessionId = A_SESSION_ID, + networkMonitor = FakeNetworkMonitor().apply { givenIsInAirGappedEnvironment(isInAirGapEnvironment) }, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt new file mode 100644 index 0000000000..09d1d7784f --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt @@ -0,0 +1,220 @@ +/* + * 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.search.workmanager + +import androidx.lifecycle.Lifecycle +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor +import androidx.work.workDataOf +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.ListenableFuture +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.FakeSdkPendingTask +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.tests.testutils.FakeLifecycleOwner +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchBackfillWorkerTest : RobolectricTest() { + @Test + fun `missing session id - fails`() = runTest { + val worker = createWorker(input = null, client = FakeMatrixClient()) + + assertThat(worker.doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `client cannot be restored - fails`() = runTest { + val worker = createWorker(client = null) + + assertThat(worker.doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `backfill already running - does not start another one`() = runTest { + val startLambda = lambdaRecorder> { error("Should not be called") } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + isSearchBackfillRunningLambda = { true }, + startSearchBackfillLambda = startLambda, + ), + ) + + val result = createWorker(client = client).doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + startLambda.assertions().isNeverCalled() + } + + @Test + fun `start fails - fails`() = runTest { + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { Result.failure(IllegalStateException("boom")) }, + ), + ) + + assertThat(createWorker(client = client).doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `backfill finishes - succeeds and closes the task`() = runTest { + var running = true + val closeLambda = lambdaRecorder { } + val startLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { running }, closeLambda = closeLambda)) + } + val client = FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = startLambda)) + + val deferred = async { createWorker(client = client).doWork() } + advanceTimeBy(10.seconds) + running = false + advanceTimeBy(10.seconds) + + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.success()) + startLambda.assertions().isCalledOnce().with(value(SearchBackfillStrategy.BACKGROUND)) + closeLambda.assertions().isCalledOnce() + } + + @Test + fun `backfill times out after 10 minutes - closes the task and retries`() = runTest { + val closeLambda = lambdaRecorder { } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { Result.success(FakeSdkPendingTask(isRunningLambda = { true }, closeLambda = closeLambda)) }, + ), + ) + + val deferred = async { createWorker(client = client).doWork() } + advanceTimeBy(9.minutes) + assertThat(deferred.isCompleted).isFalse() + advanceTimeBy(2.minutes) + + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.retry()) + closeLambda.assertions().isCalledOnce() + } + + @Test + fun `backfill will not run if the app is in foreground`() = runTest { + val startSearchBackfillLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { true })) + } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = startSearchBackfillLambda, + ), + ) + + val lifecycleOwner = FakeLifecycleOwner(initialState = Lifecycle.State.RESUMED) + val deferred = async { + createWorker(client = client, lifecycleOwner = lifecycleOwner).doWork() + } + + // Advance time to allow the worker to check the lifecycle state. + advanceTimeBy(1.milliseconds) + assertThat(deferred.isCompleted).isTrue() + + // The worker should return 'retry' immediately without starting the backfill. + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.retry()) + startSearchBackfillLambda.assertions().isNeverCalled() + } + + @Test + fun `backfill will stop if the app comes to foreground while it's running`() = runTest { + val closeLambda = lambdaRecorder { } + val startSearchBackfillLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { true }, closeLambda = closeLambda)) + } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = startSearchBackfillLambda, + ), + ) + + val lifecycleOwner = FakeLifecycleOwner(initialState = Lifecycle.State.CREATED) + val deferred = async { + createWorker(client = client, lifecycleOwner = lifecycleOwner).doWork() + } + advanceTimeBy(1.seconds) + assertThat(deferred.isCompleted).isFalse() + + lifecycleOwner.givenState(Lifecycle.State.RESUMED) + + advanceTimeBy(SearchBackfillWorker.POLL_INTERVAL) + + assertThat(deferred.isCompleted).isTrue() + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.success()) + startSearchBackfillLambda.assertions().isCalledOnce() + closeLambda.assertions().isCalledOnce() + } + + private fun TestScope.createWorker( + input: String? = A_SESSION_ID.value, + client: MatrixClient?, + lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(), + ) = SearchBackfillWorker( + params = createWorkerParams( + inputData = input?.let { workDataOf(SearchBackfillWorker.SESSION_ID_PARAM to it) } ?: Data.EMPTY, + ), + context = InstrumentationRegistry.getInstrumentation().context, + matrixClientProvider = FakeMatrixClientProvider( + getClient = { client?.let { Result.success(it) } ?: Result.failure(IllegalStateException("No client")) } + ), + processLifecycleOwner = lifecycleOwner, + ) + + private fun TestScope.createWorkerParams( + inputData: Data = Data.EMPTY, + ): WorkerParameters = WorkerParameters( + UUID.randomUUID(), + inputData, + emptySet(), + WorkerParameters.RuntimeExtras(), + 0, + 0, + Executors.newSingleThreadExecutor(), + backgroundScope.coroutineContext, + WorkManagerTaskExecutor(Executors.newSingleThreadExecutor()), + MetroWorkerFactory(emptyMap()), + { _, _, _ -> FakeListenableFuture() }, + { _, _, _ -> FakeListenableFuture() }, + ) +} + +private class FakeListenableFuture : ListenableFuture { + override fun addListener(listener: Runnable, executor: Executor) = Unit + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true + override fun get(): T? = null + override fun get(timeout: Long, unit: TimeUnit?): T? = null + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = false +} 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 f176d3b006..b864daefcc 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 @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillService 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.SyncService @@ -57,6 +58,7 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -100,6 +102,7 @@ class FakeMatrixClient( override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), + private val totalUnreadCountResult: () -> Result = { Result.success(0L) }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( @@ -131,6 +134,7 @@ class FakeMatrixClient( private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, private val markAllRoomsAsReadResult: () -> Result = { Result.success(Unit) }, private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, + override val searchBackfillService: SearchBackfillService = FakeSearchBackfillService(), private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, @@ -465,4 +469,8 @@ class FakeMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return homeserverCapabilitiesProvider } + + override fun totalUnreadCount(): Result { + return totalUnreadCountResult() + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt new file mode 100644 index 0000000000..98f3b00117 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.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.SdkPendingTask +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSdkPendingTask( + private val isRunningLambda: () -> Boolean = { lambdaError() }, + private val closeLambda: () -> Unit = { lambdaError() }, +) : SdkPendingTask { + override fun isRunning(): Boolean = isRunningLambda() + + override fun close() = closeLambda() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt index d65328d7fe..4248bc62d4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetH class FakeIdentityOAuthResetHandle( override val url: String = "", + override val session: String = "", var resetOAuthLambda: () -> Result = { error("Not implemented") }, var cancelLambda: () -> Unit = { error("Not implemented") }, ) : IdentityOAuthResetHandle { @@ -26,6 +27,7 @@ class FakeIdentityOAuthResetHandle( } class FakeIdentityPasswordResetHandle( + override val session: String = "", var resetPasswordLambda: (String) -> Result = { _ -> error("Not implemented") }, var cancelLambda: () -> Unit = { error("Not implemented") }, ) : IdentityPasswordResetHandle { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt new file mode 100644 index 0000000000..33652af13e --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt @@ -0,0 +1,41 @@ +/* + * 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.search + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.search.SearchBackfillService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSearchBackfillService( + private val schedulePeriodicSearchBackfillLambda: (SearchBackfillStrategy) -> Result = { lambdaError() }, + private val cancelPeriodicSearchBackfillLambda: () -> Unit = { lambdaError() }, + private val startSearchBackfillLambda: (SearchBackfillStrategy) -> Result = { lambdaError() }, + private val isSearchBackfillRunningLambda: () -> Boolean = { false }, + private val cancelSearchBackfillLambda: () -> Result = { lambdaError() }, +) : SearchBackfillService { + override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result { + return schedulePeriodicSearchBackfillLambda(strategy) + } + + override fun cancelPeriodicSearchBackfill() { + cancelPeriodicSearchBackfillLambda() + } + + override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result { + return startSearchBackfillLambda(strategy) + } + + override fun isSearchBackfillRunning(): Boolean { + return isSearchBackfillRunningLambda() + } + + override fun cancelSearchBackfill(): Result { + return cancelSearchBackfillLambda() + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt index 5bb85fa488..174ec4ce62 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.test.sync import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,6 +35,14 @@ class FakeSyncService( override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + private val heartBeatFlow = MutableSharedFlow(extraBufferCapacity = 1) + + override val heartBeat: Flow = heartBeatFlow + + suspend fun emitHeartBeat() { + heartBeatFlow.emit(Unit) + } + suspend fun emitSyncState(syncState: SyncState) { syncStateFlow.emit(syncState) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index d13ac06bd3..07c4825cb9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MsgType import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.SendTarget import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler @@ -60,7 +61,9 @@ class FakeTimeline( receiptType: ReceiptType, ) -> Result = { _, _ -> lambdaError() - } + }, + private val retrySendResult: (eventOrTransactionId: EventOrTransactionId) -> Result = { lambdaError() }, + private val abortSendResult: (eventOrTransactionId: EventOrTransactionId) -> Result = { lambdaError() }, ) : Timeline { var sendMessageLambda: ( body: String, @@ -355,10 +358,10 @@ class FakeTimeline( ) } - var forwardEventLambda: (eventId: EventId, roomIds: List) -> Result = { _, _ -> lambdaError() } + var forwardEventsLambda: (eventIds: List, roomIds: List) -> Result = { _, _ -> lambdaError() } - override suspend fun forwardEvent(eventId: EventId, roomIds: List): Result = simulateLongTask { - forwardEventLambda(eventId, roomIds) + override suspend fun forwardEvents(eventIds: List, roomIds: List): Result = simulateLongTask { + forwardEventsLambda(eventIds, roomIds) } var createPollLambda: ( @@ -477,6 +480,14 @@ class FakeTimeline( return getLatestEventIdResult() } + override suspend fun retrySend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return retrySendResult(eventOrTransactionId) + } + + override suspend fun abortSend(eventOrTransactionId: EventOrTransactionId, sendTarget: SendTarget): Result { + return abortSendResult(eventOrTransactionId) + } + var closeCounter = 0 private set diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt index da8b872ef0..c1e156caf6 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/TimelineFixture.kt @@ -52,6 +52,8 @@ fun anEventTimelineItem( isOwn: Boolean = false, isRemote: Boolean = false, localSendState: LocalEventSendState? = null, + editSendState: LocalEventSendState? = null, + redactionSendState: LocalEventSendState? = null, reactions: ImmutableList = persistentListOf(), receipts: ImmutableList = persistentListOf(), sender: UserId = A_USER_ID, @@ -69,6 +71,8 @@ fun anEventTimelineItem( isOwn = isOwn, isRemote = isRemote, localSendState = localSendState, + editSendState = editSendState, + redactionSendState = redactionSendState, reactions = reactions, receipts = receipts, sender = sender, diff --git a/libraries/matrixmedia/api/build.gradle.kts b/libraries/matrixmedia/api/build.gradle.kts index 90c7ee635e..500b714c6a 100644 --- a/libraries/matrixmedia/api/build.gradle.kts +++ b/libraries/matrixmedia/api/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.api" + namespace = "io.element.android.libraries.matrixmedia.api" } dependencies { diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt similarity index 92% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt index 4d4dd402d6..09c0d19f2c 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/Avatar.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/Avatar.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api /** * The size in pixel of the thumbnail to generate for the avatar. 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/matrixmedia/api/ImageLoaderHolder.kt similarity index 95% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderHolder.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/ImageLoaderHolder.kt index 228fbd2cff..e78bdfc3b5 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/matrixmedia/api/ImageLoaderHolder.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient 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/matrixmedia/api/InitialsAvatarBitmapGenerator.kt similarity index 95% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/InitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/InitialsAvatarBitmapGenerator.kt index f145383e87..f77fd5f032 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/matrixmedia/api/InitialsAvatarBitmapGenerator.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import android.graphics.Bitmap import io.element.android.libraries.designsystem.components.avatar.AvatarData diff --git a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt similarity index 96% rename from libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt rename to libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt index 47841a573b..a1f4d2af04 100644 --- a/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestData.kt +++ b/libraries/matrixmedia/api/src/main/kotlin/io/element/android/libraries/matrixmedia/api/MediaRequestData.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.api import io.element.android.libraries.matrix.api.media.MediaSource diff --git a/libraries/matrixmedia/impl/build.gradle.kts b/libraries/matrixmedia/impl/build.gradle.kts index 62799b5eaa..3a05a6c1c7 100644 --- a/libraries/matrixmedia/impl/build.gradle.kts +++ b/libraries/matrixmedia/impl/build.gradle.kts @@ -13,7 +13,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.impl" + namespace = "io.element.android.libraries.matrixmedia.impl" } setupDependencyInjection() @@ -31,6 +31,5 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt similarity index 73% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt index 0b1a09bdf9..1310f0f750 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataExt.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataExt.kt @@ -5,10 +5,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.media.MediaSource +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal fun AvatarData.toMediaRequestData(): MediaRequestData { return MediaRequestData( diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt similarity index 96% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt index 9d7767e42d..264be31e6e 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactory.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactory.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import coil3.fetch.Fetcher diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt index d31b5671eb..cd9073bb51 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/CoilMediaFetcher.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/CoilMediaFetcher.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.decode.DataSource import coil3.decode.ImageSource @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.exception.isNetworkError import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.toFile +import io.element.android.libraries.matrixmedia.api.MediaRequestData import okio.Buffer import okio.FileSystem import okio.Path.Companion.toOkioPath diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt similarity index 93% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt index a0957a3b13..0aec82dcbd 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolder.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolder.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import dev.zacsweers.metro.AppScope @@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt index 71c894f305..0baa317c5c 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/DefaultInitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultInitialsAvatarBitmapGenerator.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.graphics.Canvas import android.graphics.Paint @@ -36,6 +36,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator @ContributesBinding(AppScope::class) class DefaultInitialsAvatarBitmapGenerator : InitialsAvatarBitmapGenerator { diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt similarity index 97% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt index b414560fc9..a37a5bcf53 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/ImageLoaderFactories.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/ImageLoaderFactories.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.content.Context import android.os.Build diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt similarity index 86% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt index f0cfd2047b..32c196afe7 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataFetcherFactory.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataFetcherFactory.kt @@ -6,12 +6,13 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import coil3.fetch.Fetcher import coil3.request.Options import io.element.android.libraries.matrix.api.media.MatrixMediaLoader +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal class MediaRequestDataFetcherFactory( private val matrixMediaLoader: MatrixMediaLoader, diff --git a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt similarity index 87% rename from libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt rename to libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt index 660b80500a..c8a654d634 100644 --- a/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrix/ui/media/MediaRequestDataKeyer.kt +++ b/libraries/matrixmedia/impl/src/main/kotlin/io/element/android/libraries/matrixmedia/impl/MediaRequestDataKeyer.kt @@ -6,11 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.key.Keyer import coil3.request.Options import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrixmedia.api.MediaRequestData internal class AvatarDataKeyer : Keyer { override fun key(data: AvatarData, options: Options): String? { diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt similarity index 98% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt index eefacb3065..d6989aa72a 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/AvatarDataFetcherFactoryTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import android.graphics.Bitmap import coil3.ComponentRegistry diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt similarity index 98% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt index 2bb1ddcae4..b84fa9153c 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/DefaultImageLoaderHolderTest.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/DefaultImageLoaderHolderTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import androidx.test.platform.app.InstrumentationRegistry import coil3.ImageLoader diff --git a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt similarity index 93% rename from libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt rename to libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt index bcafad0737..506db985d0 100644 --- a/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/FakeImageLoaderFactory.kt +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrixmedia/impl/FakeImageLoaderFactory.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media +package io.element.android.libraries.matrixmedia.impl import coil3.ImageLoader import io.element.android.libraries.matrix.api.media.MatrixMediaLoader diff --git a/libraries/matrixmedia/test/build.gradle.kts b/libraries/matrixmedia/test/build.gradle.kts index 5b8e966d08..86aac509c9 100644 --- a/libraries/matrixmedia/test/build.gradle.kts +++ b/libraries/matrixmedia/test/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } android { - namespace = "io.element.android.libraries.matrix.ui.media.test" + namespace = "io.element.android.libraries.matrixmedia.test" } dependencies { diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt similarity index 96% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt index d3cd7388e5..d35d29c4f8 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoader.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoader.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.ComponentRegistry import coil3.ImageLoader diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt similarity index 85% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt index 59b9efda7d..8adb434438 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeImageLoaderHolder.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeImageLoaderHolder.kt @@ -6,12 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.ImageLoader import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder class FakeImageLoaderHolder( val fakeImageLoader: ImageLoader = FakeImageLoader(), diff --git a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt similarity index 87% rename from libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt rename to libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt index 2d0df756ac..83759c4690 100644 --- a/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrix/ui/media/test/FakeInitialsAvatarBitmapGenerator.kt +++ b/libraries/matrixmedia/test/src/main/kotlin/io/element/android/libraries/matrixmedia/test/FakeInitialsAvatarBitmapGenerator.kt @@ -5,11 +5,11 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.matrix.ui.media.test +package io.element.android.libraries.matrixmedia.test import coil3.Bitmap import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator import io.element.android.tests.testutils.lambda.lambdaError class FakeInitialsAvatarBitmapGenerator( diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index 5513dc240e..408751a8b1 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -41,6 +41,4 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index 38c5732875..1b8da50426 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import kotlinx.parcelize.Parcelize @Composable 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 e3f6478b76..4c2b7feba7 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 @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.matrix.ui.components import androidx.activity.compose.BackHandler diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt index 188a8bc1a4..875925cda9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserHeader.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -57,11 +58,13 @@ fun MatrixUserHeader( verticalArrangement = Arrangement.spacedBy(2.dp) ) { // Name - DisplayNameWithStatus( - name = matrixUser.getBestName(), - status = matrixUser.displayedStatus, - nameColor = ElementTheme.colors.textPrimary, + Text( + modifier = Modifier.clipToBounds(), + text = matrixUser.getBestName(), + maxLines = 1, style = ElementTheme.typography.fontHeadingMdRegular, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, ) // Id if (matrixUser.displayName.isNullOrEmpty().not()) { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt index 29d2f469a0..36f2c674d8 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedItem.kt @@ -9,6 +9,8 @@ package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,11 +27,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.focusProperties 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.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -48,6 +55,7 @@ import io.element.android.libraries.designsystem.text.toPx import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.activationKeys import io.element.android.libraries.ui.strings.CommonStrings @Composable @@ -62,9 +70,31 @@ fun SelectedItem( modifier: Modifier = Modifier, ) { val actionRemove = stringResource(id = CommonStrings.action_remove) + val interactionSource = remember { MutableInteractionSource() } Box( modifier = modifier .width(avatarData.size.dp) + .then( + if (canRemove) { + // Let the whole item get the keyboard focus, so that the focus indicator is drawn around it. + Modifier + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyUp && event.key in activationKeys) { + onRemoveClick() + true + } else { + false + } + } + .indication( + interactionSource = interactionSource, + indication = ripple(), + ) + .focusable(interactionSource = interactionSource) + } else { + Modifier + } + ) .clearAndSetSemantics { contentDescription = a11yContentDescription if (canRemove) { @@ -130,6 +160,8 @@ fun SelectedItem( .clip(CircleShape) .size(20.dp) .align(Alignment.TopEnd) + // The whole item is focusable, not the remove button + .focusProperties { canFocus = false } .clickable( indication = ripple(), interactionSource = remember { MutableInteractionSource() }, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt index d10698662f..aac7b4015d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceRoomItemView.kt @@ -44,7 +44,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility @@ -168,9 +167,7 @@ private fun NameAndIndicatorRow( overflow = TextOverflow.Ellipsis ) if (showIndicator) { - UnreadIndicatorAtom( - color = ElementTheme.colors.unreadIndicator - ) + UnreadIndicatorAtom(count = 0) } } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt new file mode 100644 index 0000000000..2af66d7356 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCard.kt @@ -0,0 +1,184 @@ +/* + * 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.ui.components.accountinfo + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom +import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize +import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.avatar.AvatarType +import io.element.android.libraries.designsystem.icons.CompoundDrawables +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5873-996 + */ +@Composable +fun AccountInfoCard( + state: AccountInfoCardState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .border(1.dp, ElementTheme.colors.borderDisabled, RoundedCornerShape(8.dp)) + .padding(20.dp), + ) { + AccountInfoCardUser( + matrixUser = state.matrixUser, + ) + when (state.mode) { + AccountInfoCardMode.Simple -> Unit + is AccountInfoCardMode.DeviceVerification -> { + AccountInfoSeparator() + DeviceInfoRow(state.mode) + } + is AccountInfoCardMode.UserVerification -> { + AccountInfoSeparator() + Text( + text = state.mode.hint, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + Spacer(modifier = Modifier.height(8.dp)) + AccountInfoCardUser( + matrixUser = state.mode.otherUser, + ) + } + } + } +} + +@Composable +private fun AccountInfoSeparator() { + HorizontalDivider( + modifier = Modifier.padding(vertical = 20.dp), + color = ElementTheme.colors.separatorPrimary, + ) +} + +@Composable +private fun DeviceInfoRow( + mode: AccountInfoCardMode.DeviceVerification, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {}, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RoundedIconAtom( + modifier = Modifier, + size = RoundedIconAtomSize.Big, + resourceId = CompoundDrawables.ic_compound_devices + ) + Text( + text = mode.deviceName ?: mode.deviceId.value, + style = ElementTheme.typography.fontBodyMdMedium, + color = ElementTheme.colors.textPrimary, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextWithLabelMolecule( + label = stringResource(CommonStrings.common_signed_in), + text = mode.signInFormattedTimestamp, + modifier = Modifier.weight(1f), + ) + TextWithLabelMolecule( + label = stringResource(CommonStrings.common_device_id), + text = mode.deviceId.value, + modifier = Modifier.weight(1f), + spellText = true, + ) + } + } +} + +@Composable +private fun AccountInfoCardUser(matrixUser: MatrixUser) { + val subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarData = matrixUser.getAvatarData(AvatarSize.AccountInfoUser), + avatarType = AvatarType.User, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f), + ) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = matrixUser.getBestName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + // Id + subtext?.let { + Text( + text = subtext, + color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun AccountInfoCardPreview( + @PreviewParameter(AccountInfoCardStatePreviewParam::class) state: AccountInfoCardState +) = ElementPreview { + // Add some padding to view the border + Box(modifier = Modifier.padding(16.dp)) { + AccountInfoCard(state) + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt new file mode 100644 index 0000000000..22697217fb --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardState.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.ui.components.accountinfo + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.core.DeviceId +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class AccountInfoCardState( + val matrixUser: MatrixUser, + val mode: AccountInfoCardMode, +) + +@Immutable +sealed interface AccountInfoCardMode { + data object Simple : AccountInfoCardMode + + data class UserVerification( + val otherUser: MatrixUser, + val hint: String, + ) : AccountInfoCardMode + + data class DeviceVerification( + val deviceName: String?, + val deviceId: DeviceId, + val signInFormattedTimestamp: String, + ) : AccountInfoCardMode +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardStatePreviewParam.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardStatePreviewParam.kt new file mode 100644 index 0000000000..0dac3c46b7 --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/accountinfo/AccountInfoCardStatePreviewParam.kt @@ -0,0 +1,42 @@ +/* + * 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.ui.components.accountinfo + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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 + +open class AccountInfoCardStatePreviewParam : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + anAccountInfoCardState(), + anAccountInfoCardState(aMatrixUser(displayName = null)), + anAccountInfoCardState( + mode = AccountInfoCardMode.UserVerification( + otherUser = aMatrixUser(displayName = "Other User"), + hint = "User request verification hint", + ) + ), + anAccountInfoCardState( + mode = AccountInfoCardMode.DeviceVerification( + deviceName = "My Device", + deviceId = DeviceId("DEVICEID"), + signInFormattedTimestamp = "2024-06-01 12:00:00", + ) + ), + ) +} + +private fun anAccountInfoCardState( + matrixUser: MatrixUser = aMatrixUser(), + mode: AccountInfoCardMode = AccountInfoCardMode.Simple, +) = AccountInfoCardState( + matrixUser = matrixUser, + mode = mode, +) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt index 83a707347d..ac3cdf0e3b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( name = displayName, url = avatarUrl, size = size, + thumbnail = avatarThumbnail, ) fun MatrixUser.getBestName(): String { diff --git a/libraries/matrixui/src/main/res/values-ro/translations.xml b/libraries/matrixui/src/main/res/values-ro/translations.xml index 4d3eaa2364..e738d77c90 100644 --- a/libraries/matrixui/src/main/res/values-ro/translations.xml +++ b/libraries/matrixui/src/main/res/values-ro/translations.xml @@ -1,9 +1,9 @@ "Trimiteți invitația" - "Doriți să începeți o discuție cu %1$s?" + "Doriți să începeți o conversație cu %1$s?" "Trimiteți invitația?" - "În prezent, nu aveți nicio chat cu această persoană. Confirmați invitația înainte de a continua." + "În prezent, nu aveți nicio conversație cu această persoană. Confirmați invitația înainte de a continua." "Începeți o conversație cu acest nou contact?" - "%1$s (%2$s) v-a invitat." + "%1$s (%2$s) v-a trimis o invitație." diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt index 035548a4ec..ae8a04b7cd 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt @@ -33,12 +33,14 @@ class MatrixUserExtensionsTest : RobolectricTest() { userId = A_USER_ID, displayName = "displayName", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarThumbnail", ) val expected = AvatarData( id = A_USER_ID.value, name = "displayName", url = "avatarUrl", size = AvatarSize.UserHeader, + thumbnail = "avatarThumbnail", ) assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected) } diff --git a/libraries/mediapickers/api/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts index 5e3d78dbc4..9bbf322536 100644 --- a/libraries/mediapickers/api/build.gradle.kts +++ b/libraries/mediapickers/api/build.gradle.kts @@ -17,9 +17,7 @@ android { } dependencies { - implementation(projects.libraries.uiStrings) implementation(projects.libraries.core) - implementation(projects.libraries.di) testCommonDependencies(libs) } diff --git a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt similarity index 94% rename from libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt rename to libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt index 752398f7c4..bdbf517886 100644 --- a/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTest.kt +++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/api/PickerTypeTest.kt @@ -6,13 +6,12 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.mediapickers +package io.element.android.libraries.mediapickers.api import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.mediapickers.api.PickerType import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test diff --git a/libraries/mediapickers/impl/build.gradle.kts b/libraries/mediapickers/impl/build.gradle.kts index 6ed9cd97dc..8363e2b2b3 100644 --- a/libraries/mediapickers/impl/build.gradle.kts +++ b/libraries/mediapickers/impl/build.gradle.kts @@ -19,7 +19,6 @@ android { } dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.di) api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts index d7c2989871..46729db7de 100644 --- a/libraries/mediapickers/test/build.gradle.kts +++ b/libraries/mediapickers/test/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.core) - implementation(projects.libraries.di) api(projects.libraries.mediapickers.api) } diff --git a/libraries/mediaplayer/impl/build.gradle.kts b/libraries/mediaplayer/impl/build.gradle.kts index aa9140df35..0eddeb92ec 100644 --- a/libraries/mediaplayer/impl/build.gradle.kts +++ b/libraries/mediaplayer/impl/build.gradle.kts @@ -23,7 +23,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(projects.libraries.audio.api) - implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(libs.coroutines.core) diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt index f7a748b636..7807b3ac65 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt @@ -14,8 +14,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.audio.api.AudioFocus import io.element.android.libraries.audio.api.AudioFocusRequester +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.mediaplayer.api.MediaPlayer -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.TimeoutCancellationException diff --git a/libraries/mediaplayer/test/build.gradle.kts b/libraries/mediaplayer/test/build.gradle.kts index e2f1c8ac29..bef4437b24 100644 --- a/libraries/mediaplayer/test/build.gradle.kts +++ b/libraries/mediaplayer/test/build.gradle.kts @@ -19,5 +19,4 @@ dependencies { implementation(projects.tests.testutils) implementation(libs.coroutines.test) - implementation(libs.test.truth) } diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts index a9f876c67f..fd3e2cfd8e 100644 --- a/libraries/mediaupload/api/build.gradle.kts +++ b/libraries/mediaupload/api/build.gradle.kts @@ -15,11 +15,7 @@ android { } dependencies { - implementation(projects.libraries.architecture) implementation(projects.libraries.androidutils) - implementation(projects.libraries.core) - implementation(projects.libraries.di) api(projects.libraries.matrix.api) api(projects.libraries.preferences.api) - implementation(libs.coroutines.core) } 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 60dbf353eb..b0efb618b5 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,8 @@ package io.element.android.libraries.mediaupload.api +import io.element.android.libraries.matrix.api.MatrixClient + /** * Resolves how aggressively media should be compressed, combining the user's own preferences with the app defaults. */ @@ -15,3 +17,15 @@ fun interface MediaOptimizationConfigProvider { /** Returns the configuration to pass to the pre-processing and sending calls. */ suspend fun get(): MediaOptimizationConfig } + +/** + * Creates a [MediaOptimizationConfigProvider] for a given session, used outside the session scope, for instance when sharing to another account. + */ +fun interface MediaOptimizationConfigProviderFactory { + /** + * Create a [MediaOptimizationConfigProvider] using the preferences of the session of [client]. + * + * @param client the client of the session whose preferences should be used. + */ + fun create(client: MatrixClient): MediaOptimizationConfigProvider +} 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 808575f9e6..aa76473634 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 @@ -9,6 +9,7 @@ package io.element.android.libraries.mediaupload.api import android.net.Uri +import io.element.android.libraries.matrix.api.MatrixClient 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 @@ -34,9 +35,11 @@ fun interface MediaSenderRoomFactory { /** * Create a [MediaSender] for the given [JoinedRoom], with timeline mode Live. * + * @param client the client of the session the room belongs to, used to read the media preferences of that session. * @param room the room the media should be sent to. */ fun create( + client: MatrixClient, room: JoinedRoom, ): MediaSender } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.kt new file mode 100644 index 0000000000..7d2e7d1a81 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProviderFactory.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.mediaupload.impl + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory +import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory + +@ContributesBinding(AppScope::class) +class DefaultMediaOptimizationConfigProviderFactory( + private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory, + private val featureFlagService: FeatureFlagService, +) : MediaOptimizationConfigProviderFactory { + override fun create(client: MatrixClient): MediaOptimizationConfigProvider { + return DefaultMediaOptimizationConfigProvider( + sessionPreferencesStore = sessionPreferencesStoreFactory.get(client.sessionId, client.sessionCoroutineScope), + featureFlagsService = featureFlagService, + ) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt index 36c3ba99fb..a5e0b1c38a 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaSender.kt @@ -9,12 +9,13 @@ package io.element.android.libraries.mediaupload.impl import android.net.Uri +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.flatMapCatching import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.room.CreateTimelineParams @@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProviderFactory import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSenderFactory @@ -52,19 +54,20 @@ class DefaultMediaSenderFactory( } } -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultMediaSenderRoomFactory( private val preProcessor: MediaPreProcessor, - private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, + private val mediaOptimizationConfigProviderFactory: MediaOptimizationConfigProviderFactory, ) : MediaSenderRoomFactory { override fun create( + client: MatrixClient, room: JoinedRoom, ): MediaSender { return DefaultMediaSender( preProcessor = preProcessor, room = room, timelineMode = Timeline.Mode.Live, - mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, + mediaOptimizationConfigProvider = mediaOptimizationConfigProviderFactory.create(client), ) } } diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index a5d7369056..891eb4e862 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -34,7 +34,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer.midi) implementation(libs.androidx.media3.ui) implementation(libs.telephoto.zoomableimage) - implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.flick) implementation(projects.features.enterprise.api) @@ -47,7 +46,6 @@ dependencies { implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.di) implementation(projects.libraries.designsystem) - implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixmedia.api) @@ -66,9 +64,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.audio.test) testImplementation(projects.libraries.dateformatter.test) - testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.matrixui) testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.services.toolbox.test) testImplementation(libs.coroutines.core) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt index 6aff6cf136..6b3b6a30ea 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheet.kt @@ -44,11 +44,14 @@ import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.Strings +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4168-7384 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaDeleteConfirmationBottomSheet( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt index 613e3af269..6566146d86 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/fileviewer/FileViewerView.kt @@ -5,13 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.mediaviewer.impl.fileviewer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 132fb4af91..bb44b8c836 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -34,7 +34,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -99,7 +98,6 @@ import io.element.android.libraries.mediaviewer.impl.model.id import io.element.android.libraries.voiceplayer.api.VoiceMessageState import kotlinx.collections.immutable.ImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaGalleryView( state: MediaGalleryState, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt index 89e02b616a..aac834a6e2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt @@ -71,7 +71,6 @@ fun MediaFileView( modifier = Modifier.align(Alignment.CenterHorizontally), style = BigIcon.Style.Default( vectorIcon = icon, - usePrimaryTint = true, ), ) if (info != null) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt index 5367751a19..95ebb6c818 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt @@ -12,9 +12,9 @@ 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.media.MediaSource import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationState import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo sealed interface MediaItem { 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 343bf99ec9..aa4c0c92a6 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 @@ -6,8 +6,6 @@ * Please see LICENSE files in the repository root for full details. */ -@file:OptIn(ExperimentalMaterial3Api::class) - package io.element.android.libraries.mediaviewer.impl.viewer import androidx.activity.compose.BackHandler @@ -36,7 +34,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle @@ -97,8 +94,8 @@ import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSiz 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.media.MediaSource -import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.contentvalidation.collectMediaState +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState @@ -548,7 +545,6 @@ private fun rememberShowProgress(downloadedMedia: AsyncData): Boolea return showProgress } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MediaViewerTopBar( data: MediaViewerPageData.MediaViewerData, diff --git a/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml index aa713361d0..c6f1b980ed 100644 --- a/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml +++ b/libraries/mediaviewer/impl/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ "Acest fișier va fi șters din cameră și membrii nu vor mai avea acces la el." - "Ştergeţi fişierul?" + "Ștergeți fișierul?" "Verificați conexiunea la internet și încercați din nou." "Documentele, fișierele audio și mesajele vocale încărcate în această cameră vor fi afișate aici." "Nu există încă fișiere încărcate" @@ -12,7 +12,7 @@ "Imaginile și videoclipurile încărcate în această cameră vor fi afișate aici." "Nu a fost încărcat încă niciun fișier media" "Media și fișiere" - "Format fişier" + "Format fișier" "Nume fișier" "Nu mai există fișiere de afișat" "Nu mai există conținut media de afișat" diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt index 3ca702a47f..bd25229237 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPointTest.kt @@ -14,12 +14,12 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.contentscanner.api.ContentScannerService import io.element.android.features.enterprise.test.FakeEnterpriseService +import io.element.android.libraries.audio.test.FakeAudioFocus import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventContentValidationCache -import io.element.android.libraries.mediaplayer.test.FakeAudioFocus import io.element.android.libraries.mediaviewer.api.AvatarInfo import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.impl.datasource.createTimelineMediaGalleryDataSource diff --git a/libraries/network/build.gradle.kts b/libraries/network/build.gradle.kts index faabe91a1c..1546358b7b 100644 --- a/libraries/network/build.gradle.kts +++ b/libraries/network/build.gradle.kts @@ -21,7 +21,6 @@ dependencies { implementation(libs.coroutines.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) - implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) implementation(projects.libraries.preferences.api) implementation(platform(libs.network.okhttp.bom)) diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt index daaf922ce7..025bf606f5 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.network.interceptors import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.flow.first @@ -31,7 +32,12 @@ class DynamicHttpLoggingInterceptor( override fun intercept(chain: Interceptor.Chain): Response { // This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() } - loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE + // If the request body is binary, we log only the headers to avoid logging binary data + val isBinaryBody = chain.request().headers["Content-Type"] == MimeTypes.OctetStream + loggingInterceptor.level = when { + logLevel >= LogLevel.DEBUG -> if (isBinaryBody) Level.HEADERS else Level.BODY + else -> Level.NONE + } return loggingInterceptor.intercept(chain) } } diff --git a/libraries/oauth/impl/build.gradle.kts b/libraries/oauth/impl/build.gradle.kts index d051c06497..5a3b605277 100644 --- a/libraries/oauth/impl/build.gradle.kts +++ b/libraries/oauth/impl/build.gradle.kts @@ -28,20 +28,13 @@ android { setupDependencyInjection() dependencies { - implementation(projects.appconfig) - implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(libs.androidx.browser) implementation(platform(libs.network.retrofit.bom)) - implementation(libs.network.retrofit) - implementation(libs.serialization.json) api(projects.libraries.oauth.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) - testImplementation(projects.libraries.permissions.test) } diff --git a/libraries/permissions/api/src/main/res/values-ro/translations.xml b/libraries/permissions/api/src/main/res/values-ro/translations.xml index 30b6e4d9a5..5c7a8e61a3 100644 --- a/libraries/permissions/api/src/main/res/values-ro/translations.xml +++ b/libraries/permissions/api/src/main/res/values-ro/translations.xml @@ -1,7 +1,7 @@ - "Pentru a permite aplicației să utilizeze camera, vă rugăm să acordați permisiunea în setările sistemului." - "Vă rugăm să acordați permisiunea în setările sistemului." - "Pentru a permite aplicației să utilizeze microfonul, vă rugăm să acordați permisiunea în setările sistemului." - "Pentru a permite aplicației să afișeze notificări, vă rugăm să acordați permisiunea în setările sistemului." + "Pentru a permite aplicației să utilizeze camera dispozitivului, acordați permisiunea în setările sistemului." + "Acordați permisiunea în setările sistemului." + "Pentru a permite aplicației să utilizeze microfonul, acordați permisiunea în setările sistemului." + "Pentru a permite aplicației să afișeze notificări, acordați permisiunea în setările sistemului." diff --git a/libraries/permissions/impl/build.gradle.kts b/libraries/permissions/impl/build.gradle.kts index 70e27db7f0..fa033dc064 100644 --- a/libraries/permissions/impl/build.gradle.kts +++ b/libraries/permissions/impl/build.gradle.kts @@ -32,11 +32,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.troubleshoot.api) - implementation(projects.libraries.designsystem) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.preferences.api) implementation(projects.services.toolbox.api) api(projects.libraries.permissions.api) 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 9dd53ade09..16226813d2 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 @@ -12,7 +12,7 @@ import android.graphics.Bitmap import androidx.core.graphics.drawable.IconCompat 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 +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL /** * Loads the avatars used to decorate notifications, since the notification APIs need bitmaps rather than Compose images. 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 95966f8eb1..0e41d63e26 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 @@ -65,4 +65,11 @@ interface NotificationCleaner { * @param roomId the room whose notifications are dismissed. */ fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) + + /** + * Dismisses the notifications of every read room. + * + * @param sessionId the session whose notifications should be dismissed. + */ + fun clearReadRoomsNotifications(sessionId: SessionId) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 8615485bba..cd83392383 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -86,7 +86,6 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.libraries.workmanager.api) - implementation(projects.features.announcement.api) implementation(projects.features.call.api) implementation(projects.features.enterprise.api) implementation(projects.features.lockscreen.api) @@ -110,7 +109,6 @@ dependencies { testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.troubleshoot.test) testImplementation(projects.libraries.workmanager.test) - testImplementation(projects.features.announcement.test) testImplementation(projects.features.call.test) testImplementation(projects.features.enterprise.test) testImplementation(projects.features.lockscreen.test) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt index ab6f01e423..f1c4c9d9a3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -14,6 +14,7 @@ import android.os.PowerManager import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.core.EventId @@ -23,23 +24,25 @@ import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.db.PushHistory import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.withContext import kotlin.time.Instant @ContributesBinding(AppScope::class) class DefaultPushHistoryService( private val pushDatabase: PushDatabase, private val systemClock: SystemClock, + private val dispatchers: CoroutineDispatchers, @ApplicationContext context: Context, ) : PushHistoryService { private val powerManager = context.getSystemService() private val packageName = context.packageName - override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { - return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } } - override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { - return runCatchingExceptions { + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transaction { for (request in pushRequests) { pushDatabase.pushRequestQueries.insertPushRequest(request) @@ -48,8 +51,8 @@ class DefaultPushHistoryService( } } - override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { - return runCatchingExceptions { + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transactionWithResult { val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() @@ -57,14 +60,14 @@ class DefaultPushHistoryService( } } - override suspend fun removeOldPushRequests(sessionId: SessionId): Result { - return runCatchingExceptions { + override suspend fun removeOldPushRequests(sessionId: SessionId): Result = withContext(dispatchers.io) { + runCatchingExceptions { val keepAmount = 100L pushDatabase.pushRequestQueries.removeOldest(keepAmount) } } - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -91,19 +94,21 @@ class DefaultPushHistoryService( } }.takeIf { it.isNotEmpty() } - pushDatabase.pushHistoryQueries.insertPushHistory( - PushHistory( - pushDate = systemClock.epochMillis(), - providerInfo = providerInfo, - eventId = eventId?.value, - roomId = roomId?.value, - sessionId = sessionId?.value, - hasBeenResolved = if (hasBeenResolved) 1 else 0, - comment = finalComment, + withContext(dispatchers.io) { + pushDatabase.pushHistoryQueries.insertPushHistory( + PushHistory( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = eventId?.value, + roomId = roomId?.value, + sessionId = sessionId?.value, + hasBeenResolved = if (hasBeenResolved) 1 else 0, + comment = finalComment, + ) ) - ) - // Keep only the last 1_000 events - pushDatabase.pushHistoryQueries.removeOldest(1_000) + // Keep only the last 1_000 events + pushDatabase.pushHistoryQueries.removeOldest(1_000) + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt index 3996924322..298abbd441 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -20,7 +20,7 @@ interface PushHistoryService { * Create a new push history entry. * Do not use directly, prefer using the extension functions. */ - fun onPushResult( + suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -52,7 +52,7 @@ interface PushHistoryService { suspend fun removeOldPushRequests(sessionId: SessionId): Result } -fun PushHistoryService.onInvalidPushReceived( +suspend fun PushHistoryService.onInvalidPushReceived( providerInfo: String, data: String, ) = onPushResult( @@ -65,7 +65,7 @@ fun PushHistoryService.onInvalidPushReceived( comment = "Invalid or ignored push data:\n$data", ) -fun PushHistoryService.onUnableToRetrieveSession( +suspend fun PushHistoryService.onUnableToRetrieveSession( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -80,7 +80,7 @@ fun PushHistoryService.onUnableToRetrieveSession( comment = "Unable to retrieve session: $reason", ) -fun PushHistoryService.onUnableToResolveEvent( +suspend fun PushHistoryService.onUnableToResolveEvent( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -96,7 +96,7 @@ fun PushHistoryService.onUnableToResolveEvent( comment = "Unable to resolve event: $reason", ) -fun PushHistoryService.onSuccess( +suspend fun PushHistoryService.onSuccess( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -117,7 +117,7 @@ fun PushHistoryService.onSuccess( }, ) -fun PushHistoryService.onDiagnosticPush( +suspend fun PushHistoryService.onDiagnosticPush( providerInfo: String, ) = onPushResult( providerInfo = providerInfo, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt index 086914ecf5..e891fa3ea6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationBitmapLoader.kt @@ -23,9 +23,9 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.media.MediaSource -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL -import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator -import io.element.android.libraries.matrix.ui.media.MediaRequestData +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.InitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.MediaRequestData import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import timber.log.Timber diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 6834f404ed..8e5b19505d 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -19,7 +19,7 @@ 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 import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator @@ -53,7 +53,7 @@ class DefaultNotificationDrawerManager( private val notificationRenderer: NotificationRenderer, private val appNavigationStateService: AppNavigationStateService, @AppCoroutineScope - coroutineScope: CoroutineScope, + private val coroutineScope: CoroutineScope, private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, private val activeNotificationsProvider: ActiveNotificationsProvider, @@ -187,6 +187,31 @@ class DefaultNotificationDrawerManager( clearSummaryNotificationIfNeeded(sessionId) } + override fun clearReadRoomsNotifications(sessionId: SessionId) { + val notifications = activeNotificationsProvider.getNotificationsForSession(sessionId) + val notificationsGroupedByRoomId = notifications.mapNotNull { + it.tag?.let(NotificationCreator::getMessageNotificationRoomId)?.let { roomId -> roomId to it } + } + + if (notificationsGroupedByRoomId.isEmpty()) return + + coroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { return@launch } + + notificationsGroupedByRoomId.forEach { (roomId, notification) -> + val roomInfo = client.getRoomInfo(roomId).getOrNull() ?: return@forEach + + if (roomInfo.numUnreadNotifications > 0L || roomInfo.numUnreadMessages > 0 || roomInfo.numUnreadMentions > 0) { + return@forEach + } + + notificationDisplayer.cancelNotification(notification.tag, notification.id) + } + + clearSummaryNotificationIfNeeded(sessionId) + } + } + private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt index 7b08958cd1..583a10f6fb 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationService.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder +import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService import io.element.android.libraries.push.impl.intent.IntentProvider 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 26412abebc..3394a2730c 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 @@ -24,6 +24,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.timeline.item.event.EventType @@ -102,11 +103,25 @@ interface NotificationCreator { /** * Creates a tag for a message notification given its [roomId] and optional [threadId]. */ - fun messageTag(roomId: RoomId, threadId: ThreadId?): String = if (threadId != null) { - "$roomId|$threadId" - } else { - roomId.value + fun messageTag(roomId: RoomId, threadId: ThreadId?): String = buildString { + append(roomId) + threadId?.let { + append(SEPARATOR) + append(it) + } } + + /** + * Extracts the [RoomId] from a message notification [tag] created by [messageTag]. + * Returns null if the tag does not start with a valid room id. + */ + fun getMessageNotificationRoomId(tag: String): RoomId? { + return tag.substringBefore(SEPARATOR) + .takeIf(MatrixPatterns::isRoomId) + ?.let(::RoomId) + } + + private const val SEPARATOR = "|" } } diff --git a/libraries/push/impl/src/main/res/values-cs/translations.xml b/libraries/push/impl/src/main/res/values-cs/translations.xml index 03fac59852..b371f68a65 100644 --- a/libraries/push/impl/src/main/res/values-cs/translations.xml +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -50,7 +50,7 @@ "%1$s zmínil(a) nebo odpověděl(a)" "Pozvali vás do prostoru" "%1$s vás pozvali do prostoru" - "Prohlížíte si oznámení! Klikněte na mě!" + "Prohlížíte si oznámení! Klepněte na mě!" "Vlákno v %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -100,7 +100,7 @@ "Oznámení nelze zobrazit." "Na oznámení bylo kliknuto!" "Zobrazit oznámení" - "Kliknutím na oznámení pokračujte v testu." + "Klepnutím na oznámení pokračujte v testu." "Ujistěte se, že aplikace přijímá push." "Chyba: pusher odmítl požadavek." "Chyba: %1$s." diff --git a/libraries/push/impl/src/main/res/values-in/translations.xml b/libraries/push/impl/src/main/res/values-in/translations.xml index db9147c0cf..fcd8eaa76b 100644 --- a/libraries/push/impl/src/main/res/values-in/translations.xml +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -45,7 +45,7 @@ "%1$s disebut atau dibalas" "Kami mengundang Anda untuk bergabung di ruang ini." "%1$s mengundang Anda untuk bergabung ke ruang ini." - "Anda sedang melihat pemberitahuan ini! Klik saya!" + "Anda sedang melihat notifikasi ini! Klik saya!" "Diskusi di %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" @@ -87,7 +87,7 @@ "Tidak dapat menampilkan notifikasi." "Notifikasi telah diklik!" "Tampilan notifikasi" - "Silakan klik pada notifikasi untuk melanjutkan tes." + "Silakan klik notifikasi untuk melanjutkan tes." "Pastikan aplikasi menerima notifikasi dorongan." "Kesalahan: pendorong telah menolak permintaan." "Kesalahan: %1$s." 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 109a50f7aa..00daae2895 100644 --- a/libraries/push/impl/src/main/res/values-ro/translations.xml +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -1,71 +1,78 @@ "Apel" - "Ascultare evenimente" + "Se așteaptă evenimente" "Notificări zgomotoase" "Apeluri care sună" "Notificări silențioase" "%1$s: %2$d mesaj" - "%1$s: %2$d mesaje" + "%1$s: %2$d mesaje" + "%1$s: %2$d de mesaje" "%d notificare" - "%d notificări" + "%d notificări" + "%d de notificări" "Distribuitorul de notificări UnifiedPush nu a putut fi înregistrat, așadar nu veți mai primi notificări. Verificați setările de notificări ale aplicației și starea distribuitorului push." "Aveți mesaje noi" "Aveți %d mesaj nou." "Aveți %d mesaje noi." - "Aveți %d mesaje noi." + "Aveți %d de mesaje noi." "%1$d atașament partajat" "%1$d atașamente partajate" - "%1$d atașamente partajate" + "%1$d de atașamente partajate" "📞 Apel primit" - "Apel primit" - "** Trimiterea eșuată - vă rugăm să deschideți camera" + "📹 Apel primit" + "** Nu s-a putut trimite - deschideți camera" "Alăturați-vă" "Respingeți" "%d invitație" - "%d invitații" + "%d invitații" + "%d de invitații" - "V-a invitat la o discuție" - "%1$s v-a invitat să discutați" - "A început să își partajeze locația live" + "V-a invitat să conversați" + "%1$s v-a invitat să conversați" + "A trimis o cerere de alăturare" + "A început să își partajeze locația în timp real" "%1$s v-a menționat" "Mesaje noi" "%d mesaj nou" - "%d mesaje noi" + "%d mesaje noi" + "%d de mesaje noi" "A reacționat cu %1$s" "Marcați ca citită" - "Raspuns rapid" + "Răspuns rapid" "V-a invitat să vă alăturați camerei" "%1$s v-a invitat să vă alăturați camerei" "Eu" - "%1$s v-a menționat sau răspuns" + "%1$s v-a menționat sau a răspuns" "V-a invitat să vă alăturați spațiului" "%1$s v-a invitat să vă alăturați spațiului" - "Vizualizați o notificare! Faceți clic pe mine!" - "Fir în: %1$s" + "Vizualizați o notificare! Atingeți-mă!" + "Fir în %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" "%d mesaj notificat necitit" - "%d mesaje notificate necitite" + "%d mesaje notificate necitite" + "%d de mesaje notificate necitite" "%1$s și %2$s" "%1$s în %2$s" "%1$s în %2$s și %3$s" "%d cameră" - "%d camere" + "%d camere" + "%d de camere" "Sincronizare în fundal" "Servicii Google" @@ -74,38 +81,39 @@ "Vizualizați utilizatorii blocați" "Niciun utilizator nu este blocat." - "Ai blocat%1$d utilizator. Nu veți primi notificări pentru acest utilizator." - "Ai blocat%1$d utilizatori. Nu veți primi notificări pentru acești utilizatori." - "Ai blocat%1$d utilizatori. Nu veți primi notificări pentru acești utilizatori." + "Ați blocat %1$d utilizator. Nu veți primi notificări pentru acest utilizator." + "Ați blocat %1$d utilizatori. Nu veți primi notificări pentru acești utilizatori." + "Ați blocat %1$d de utilizatori. Nu veți primi notificări pentru acești utilizatori." "Utilizatori blocați" - "Obțineți numele furnizorului curent." + "Obțineți numele furnizorului actual." "Niciun furnizor push selectat." "Furnizorul actual de push: %1$s și distribuitorul actual: %2$s. Dar distribuitorul %3$s nu este găsit. Poate că aplicația a fost dezinstalată?" - "Furnizor push actual: %1$s, dar nu au fost configurați distribuitori." + "Furnizor de push actual: %1$s, dar nu au fost configurați distribuitori." "Furnizor de push actual: %1$s." - "Furnizor actual de push: %1$s (%2$s)" - "Furnizor de push curent" + "Furnizor de push actual: %1$s (%2$s)" + "Furnizor de push actual" "Asigurați-vă că aplicația are cel puțin un furnizor push." "Nu s-au găsit furnizori push." "S-a găsit %1$d furnizor push: %2$s" "S-au găsit %1$d furnizori push: %2$s" - "S-au găsit %1$d furnizori push: %2$s" + "S-au găsit %1$d de furnizori push: %2$s" "Aplicația a fost creată cu suport pentru: %1$s" - "Detectați furnizorii push" + "Furnizorii push acceptați" "Verificați dacă aplicația poate afișa notificări." "Notificarea nu a fost apăsată." "Nu s-a putut afișa notificarea." "Notificarea a fost apăsată!" "Afișați notificarea" - "Vă rugăm să faceți clic pe notificare pentru a continua testul." + "Atingeți notificare pentru a continua testul." "Asigurați-vă că aplicația primește push-uri." - "Eroare: pusher-ul a respins cererea." + "Eroare: pusherul a respins cererea." "Eroare: %1$s." "Eroare, nu se poate testa push-ul." "Eroare, timeout în așteptare pentru push." + "Eroare: gateway-ul push limitează rata de acces a acestui dispozitiv, astfel încât notificările nu vor fi fiabile. Configurați alt gateway push." "Push-ul înapoi a durat %1$d ms." "Testați că push-ul se întoarce" 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 a2f9018921..b50cc36418 100644 --- a/libraries/push/impl/src/main/res/values-zh/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -9,7 +9,7 @@ "%1$s:%2$d 条消息" - "%d 个通知" + "%d 条通知" "UnifiedPush 分发器注册失败,你将无法再接收通知。请检查 app 的通知设置及推送分发器的状态。" "你有新消息。" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt index aa9612717a..e7cd342514 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -30,7 +30,7 @@ class FakePushHistoryService( private val getPendingPushRequests: (SessionId, Instant?) -> Result> = { _, _ -> lambdaError() }, private val removeOldPushRequests: (SessionId) -> Result = { lambdaError() }, ) : PushHistoryService { - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt index 408063712a..17ab5b3967 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultBaseRoomGroupMessageCreatorTest.kt @@ -19,10 +19,10 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.AVATAR_THUMBNAIL_SIZE_IN_PIXEL -import io.element.android.libraries.matrix.ui.media.MediaRequestData -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader -import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.api.AVATAR_THUMBNAIL_SIZE_IN_PIXEL +import io.element.android.libraries.matrixmedia.api.MediaRequestData +import io.element.android.libraries.matrixmedia.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.impl.notifications.factories.MARK_AS_READ_ACTION_TITLE import io.element.android.libraries.push.impl.notifications.factories.QUICK_REPLY_ACTION_TITLE import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index cdc6a01fad..0cb6997ffa 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification +import android.service.notification.StatusBarNotification import androidx.compose.ui.graphics.Color import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.api.EnterpriseService @@ -16,6 +17,8 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.test.FakeLockScreenService import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomInfo 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_ROOM_ID @@ -27,8 +30,9 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID_2 import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator @@ -57,6 +61,7 @@ import io.element.android.services.appnavstate.test.aNavigationState import io.element.android.services.appnavstate.test.anAppNavigationState import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -569,6 +574,163 @@ class DefaultNotificationDrawerManagerTest { createFallbackNotificationResult.assertions().isNeverCalled() } + @Test + fun `clearReadRoomsNotifications cancels notifications of rooms without unread counts`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledOnce().with(value(A_ROOM_ID.value), value(1)) + } + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread messages`() = testKeepsNotification( + aRoomInfo(id = A_ROOM_ID, numUnreadMessages = 1) + ) + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread notifications`() = + testKeepsNotification(aRoomInfo(id = A_ROOM_ID, numUnreadNotifications = 1)) + + @Test + fun `clearReadRoomsNotifications keeps notifications of rooms with unread mentions`() = testKeepsNotification( + aRoomInfo(id = A_ROOM_ID, numUnreadMentions = 1) + ) + + @Test + fun `clearReadRoomsNotifications keeps notifications when the room info is not found`() = testKeepsNotification(null) + + @Test + fun `clearReadRoomsNotifications does nothing when the client cannot be restored`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + clientResult = Result.failure(IllegalStateException("No client")), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications ignores notifications not linked to a room`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf( + mockk { + every { id } returns 1 + every { tag } returns null + }, + ), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications cancels thread notifications of rooms without unread counts`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val threadTag = "$A_ROOM_ID|$A_THREAD_ID" + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf( + mockk { + every { id } returns 1 + every { tag } returns threadTag + }, + ), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledOnce().with(value(threadTag), value(1)) + } + + @Test + fun `clearReadRoomsNotifications ignores notifications whose tag is not a room id`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf( + mockk { + every { id } returns 1 + every { tag } returns AN_EVENT_ID.value + }, + ), + roomInfo = aRoomInfo(id = A_ROOM_ID), + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + @Test + fun `clearReadRoomsNotifications clears the summary when it is the only notification left`() = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID) + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = aRoomInfo(id = A_ROOM_ID), + summaryNotification = mockk { every { id } returns summaryId }, + count = 1, + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(1)), + listOf(value(null), value(summaryId)), + ) + } + + private fun testKeepsNotification(roomInfo: RoomInfo?) = runTest { + val cancelNotificationResult = lambdaRecorder { _, _ -> } + val sut = createClearReadRoomsManager( + cancelNotificationResult = cancelNotificationResult, + notifications = listOf(aRoomStatusBarNotification(A_ROOM_ID, 1)), + roomInfo = roomInfo, + ) + sut.clearReadRoomsNotifications(A_SESSION_ID) + runCurrent() + cancelNotificationResult.assertions().isNeverCalled() + } + + private fun aRoomStatusBarNotification(roomId: RoomId, notificationId: Int): StatusBarNotification = mockk { + every { id } returns notificationId + every { tag } returns roomId.value + } + + private fun TestScope.createClearReadRoomsManager( + cancelNotificationResult: LambdaTwoParamsRecorder, + notifications: List, + roomInfo: RoomInfo?, + clientResult: Result? = null, + summaryNotification: StatusBarNotification? = null, + count: Int = notifications.size, + ): DefaultNotificationDrawerManager { + val matrixClient = FakeMatrixClient().apply { + getRoomInfoLambda = { roomId -> Result.success(roomInfo?.takeIf { it.id == roomId }) } + } + return createDefaultNotificationDrawerManager( + notificationDisplayer = FakeNotificationDisplayer(cancelNotificationResult = cancelNotificationResult), + activeNotificationsProvider = FakeActiveNotificationsProvider( + getNotificationsForSessionResult = { notifications }, + getSummaryNotificationResult = { summaryNotification }, + countResult = { count }, + ), + matrixClientProvider = FakeMatrixClientProvider(getClient = { clientResult ?: Result.success(matrixClient) }), + ) + } + /** * The content of the original event must be stripped, only the noisy flag, the ids and the timestamp are kept. */ diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt index 1a46546db8..46845fd55b 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -14,7 +14,7 @@ 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_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeImageLoader import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 43991444fe..4a1970b4f2 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -14,7 +14,7 @@ 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_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeImageLoader import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt index cb5fad7509..0296836ccb 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/conversations/DefaultNotificationConversationServiceTest.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2 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.FakeMatrixClientProvider -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder +import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder import io.element.android.libraries.push.impl.notifications.factories.FakeIntentProvider import io.element.android.libraries.push.impl.notifications.shortcut.createShortcutId import io.element.android.libraries.push.test.notifications.push.FakeNotificationBitmapLoader 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 ead16c02d7..513db1ef0c 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 @@ -27,8 +27,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.ui.components.aMatrixUser -import io.element.android.libraries.matrix.ui.media.test.FakeImageLoader -import io.element.android.libraries.matrix.ui.media.test.FakeInitialsAvatarBitmapGenerator +import io.element.android.libraries.matrixmedia.test.FakeImageLoader +import io.element.android.libraries.matrixmedia.test.FakeInitialsAvatarBitmapGenerator import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.NotificationActionIds diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt new file mode 100644 index 0000000000..1c4c6df895 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.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.push.impl.notifications.factories + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import org.junit.Test + +class NotificationCreatorTest { + @Test + fun `getMessageNotificationRoomId returns the room id of a room message tag`() { + val tag = NotificationCreator.messageTag(A_ROOM_ID, null) + assertThat(tag).isEqualTo(A_ROOM_ID.value) + assertThat(NotificationCreator.getMessageNotificationRoomId(tag)).isEqualTo(A_ROOM_ID) + } + + @Test + fun `getMessageNotificationRoomId returns the room id of a thread message tag`() { + val tag = NotificationCreator.messageTag(A_ROOM_ID, A_THREAD_ID) + assertThat(tag).isEqualTo("${A_ROOM_ID.value}|${A_THREAD_ID.value}") + assertThat(NotificationCreator.getMessageNotificationRoomId(tag)).isEqualTo(A_ROOM_ID) + } + + @Test + fun `getMessageNotificationRoomId returns null for a tag which is not a room id`() { + assertThat(NotificationCreator.getMessageNotificationRoomId(AN_EVENT_ID.value)).isNull() + assertThat(NotificationCreator.getMessageNotificationRoomId("")).isNull() + } +} diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts index d125a2387e..f97a23a907 100644 --- a/libraries/push/test/build.gradle.kts +++ b/libraries/push/test/build.gradle.kts @@ -20,7 +20,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api) implementation(projects.libraries.preferences.api) - implementation(projects.libraries.matrixui) implementation(projects.libraries.workmanager.api) implementation(projects.tests.testutils) implementation(libs.androidx.core) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt index 28a249dc9b..82202111dd 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationCleaner.kt @@ -21,7 +21,8 @@ class FakeNotificationCleaner( private val clearMessagesForThreadLambda: (SessionId, RoomId, ThreadId) -> Unit = { _, _, _ -> lambdaError() }, private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() }, private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() }, - private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() } + private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }, + private val clearReadRoomsNotificationsLambda: (SessionId) -> Unit = { lambdaError() }, ) : NotificationCleaner { override fun clearAllMessagesEvents(sessionId: SessionId) { clearAllMessagesEventsLambda(sessionId) @@ -46,4 +47,8 @@ class FakeNotificationCleaner( override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { clearMembershipNotificationForRoomLambda(sessionId, roomId) } + + override fun clearReadRoomsNotifications(sessionId: SessionId) { + clearReadRoomsNotificationsLambda(sessionId) + } } diff --git a/libraries/pushproviders/api/build.gradle.kts b/libraries/pushproviders/api/build.gradle.kts index 3581a43952..d489ec5bfe 100644 --- a/libraries/pushproviders/api/build.gradle.kts +++ b/libraries/pushproviders/api/build.gradle.kts @@ -14,6 +14,5 @@ android { } dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) } diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index cfe5dd01fb..35fef58fa0 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -51,7 +51,6 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) implementation(projects.libraries.sessionStorage.api) - implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) implementation(projects.services.toolbox.api) @@ -66,8 +65,6 @@ dependencies { } testCommonDependencies(libs) - testImplementation(libs.kotlinx.collections.immutable) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushstore.test) diff --git a/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml index 72e13904d5..6885347b57 100644 --- a/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml +++ b/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml @@ -7,5 +7,5 @@ "Asigurați-vă că tokenul Firebase este disponibil." "Tokenul Firebase nu este cunoscut." "Token Firebase: %1$s." - "Verificați token-ul Firebase" + "Verificați tokenul Firebase" diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index 1b24bc4b1f..7aa19b093b 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) - implementation(projects.libraries.uiStrings) api(projects.libraries.troubleshoot.api) implementation(projects.libraries.pushstore.api) @@ -46,8 +45,6 @@ dependencies { api(libs.unifiedpush) testCommonDependencies(libs) - testImplementation(libs.kotlinx.collections.immutable) - testImplementation(projects.features.enterprise.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml index fa96a07474..080635ea2d 100644 --- a/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml @@ -5,7 +5,7 @@ "%1$d distribuitor găsit: %2$s." "%1$d distribuitori găsiți: %2$s." - "%1$d distribuitori găsiți: %2$s." + "%1$d de distribuitori găsiți: %2$s." "Verificați UnifiedPush" diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 4e643fd5f9..8691bd2196 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -13,7 +13,7 @@ plugins { } android { - namespace = "io.element.android.libraries.push.pushstore.impl" + namespace = "io.element.android.libraries.pushstore.impl" defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt index a3b31acf6c..d06bd43582 100644 --- a/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt +++ b/libraries/pushstore/impl/src/androidTest/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactoryTest.kt @@ -18,7 +18,7 @@ import kotlin.concurrent.thread /** * Note: to clear the emulator, invoke: - * adb uninstall io.element.android.libraries.push.pushstore.impl.test + * adb uninstall io.element.android.libraries.pushstore.impl.test */ class DefaultUserPushStoreFactoryTest { /** diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt index 3c5c5d5ac3..39025a95d1 100644 --- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectEntryPoint.kt @@ -13,9 +13,16 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId interface RoomSelectEntryPoint : FeatureEntryPoint { + /** + * @param sessionId the session to use initially. In [RoomSelectMode.Share] mode, the user can select another session. + * @param mode the mode of the screen. + * @param maxNumberOfRooms the maximum number of rooms the user can select. + */ data class Params( + val sessionId: SessionId, val mode: RoomSelectMode, val maxNumberOfRooms: Int, ) @@ -28,8 +35,19 @@ interface RoomSelectEntryPoint : FeatureEntryPoint { ): Node interface Callback : Plugin { - fun onRoomSelected(roomIds: List) + /** + * @param sessionId the session which has been selected by the user, the rooms belong to this session. + * @param roomIds the selected rooms. + */ + fun onRoomSelected(sessionId: SessionId, roomIds: List) fun onCancel() + + /** + * Called in [RoomSelectMode.Share] mode when the user has selected an account whose session is not verified. + * The session has to be verified before the user can share content with it. + * @param sessionId the session to verify. + */ + fun onSessionVerificationRequired(sessionId: SessionId) } companion object { diff --git a/libraries/roomselect/impl/build.gradle.kts b/libraries/roomselect/impl/build.gradle.kts index 07443bbf9d..281e1025b8 100644 --- a/libraries/roomselect/impl/build.gradle.kts +++ b/libraries/roomselect/impl/build.gradle.kts @@ -32,10 +32,12 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) api(projects.libraries.roomselect.api) testCommonDependencies(libs, includeTestComposeView = true) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt index ff3ba06f0d..076c1209ee 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPoint.kt @@ -10,12 +10,12 @@ package io.element.android.libraries.roomselect.impl import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.architecture.createNode -import io.element.android.libraries.di.SessionScope import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint -@ContributesBinding(SessionScope::class) +@ContributesBinding(AppScope::class) class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { override fun createNode( parentNode: Node, @@ -27,6 +27,7 @@ class DefaultRoomSelectEntryPoint : RoomSelectEntryPoint { buildContext = buildContext, plugins = listOf( RoomSelectNode.Inputs( + sessionId = params.sessionId, mode = params.mode, maxNumberOfRooms = params.maxNumberOfRooms, ), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt index 13c5bc5f8d..d08bce5026 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectEvent.kt @@ -8,10 +8,12 @@ package io.element.android.libraries.roomselect.impl +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.ui.model.SelectRoomInfo sealed interface RoomSelectEvent { data class ToggleSelectedRoom(val room: SelectRoomInfo) : RoomSelectEvent - data object ToggleSearchActive : RoomSelectEvent data class UpdateVisibleRange(val range: IntRange) : RoomSelectEvent + data object ToggleAccountListExpanded : RoomSelectEvent + data class SelectAccount(val sessionId: SessionId) : RoomSelectEvent } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.kt new file mode 100644 index 0000000000..1b7747736e --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNavigator.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.roomselect.impl + +import io.element.android.libraries.matrix.api.core.SessionId + +fun interface RoomSelectNavigator { + /** + * Navigate to the verification of the session [sessionId], which has to be verified before content can be shared with it. + */ + fun navigateToSessionVerification(sessionId: SessionId) +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt index 0297f13066..148b8bf53d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectNode.kt @@ -15,6 +15,7 @@ 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 @@ -22,29 +23,36 @@ import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs -import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode -@ContributesNode(SessionScope::class) +@ContributesNode(AppScope::class) @AssistedInject class RoomSelectNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: RoomSelectPresenter.Factory, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), RoomSelectNavigator { data class Inputs( + val sessionId: SessionId, val mode: RoomSelectMode, val maxNumberOfRooms: Int, ) : NodeInputs private val inputs: Inputs = inputs() + private val callback: RoomSelectEntryPoint.Callback = callback() private val presenter = presenterFactory.create( + initialSessionId = inputs.sessionId, mode = inputs.mode, maxNumberOfRooms = inputs.maxNumberOfRooms, + navigator = this, ) private val stateFlow = launchMolecule { presenter.present() } - private val callback: RoomSelectEntryPoint.Callback = callback() + + override fun navigateToSessionVerification(sessionId: SessionId) { + callback.onSessionVerificationRequired(sessionId) + } @Composable override fun View(modifier: Modifier) { @@ -52,7 +60,7 @@ class RoomSelectNode( RoomSelectView( state = state, onDismiss = callback::onCancel, - onSubmit = callback::onRoomSelected, + onSubmit = { roomIds -> callback.onRoomSelected(state.selectedAccount.userId, roomIds) }, modifier = modifier ) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 618f30a3e8..6a9f367e0b 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -15,7 +15,9 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -24,24 +26,39 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.api.user.toMatrixUser +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import timber.log.Timber @AssistedInject class RoomSelectPresenter( + @Assisted private val initialSessionId: SessionId, @Assisted private val mode: RoomSelectMode, @Assisted private val maxNumberOfRooms: Int, + @Assisted private val navigator: RoomSelectNavigator, private val dataSourceFactory: RoomSelectSearchDataSource.Factory, + private val matrixClientProvider: MatrixClientProvider, + private val sessionStore: SessionStore, ) : Presenter { @AssistedFactory fun interface Factory { fun create( + initialSessionId: SessionId, mode: RoomSelectMode, maxNumberOfRooms: Int, + navigator: RoomSelectNavigator, ): RoomSelectPresenter } @@ -49,23 +66,96 @@ class RoomSelectPresenter( override fun present(): RoomSelectState { var selectedRooms by remember { mutableStateOf(persistentListOf()) } val queryState = rememberTextFieldState() - var isSearchActive by remember { mutableStateOf(false) } - - val coroutineScope = rememberCoroutineScope() - val dataSource = remember { dataSourceFactory.create(coroutineScope) } - - val searchQuery = queryState.text.toString() - LaunchedEffect(searchQuery) { - dataSource.setSearchQuery(searchQuery) + var isAccountListExpanded by remember { mutableStateOf(false) } + // Null until the sessions are loaded + val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null) + var selectedSessionId by remember { mutableStateOf(initialSessionId) } + // All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error. + val accounts by remember { + derivedStateOf { sessions.orEmpty().map { it.toMatrixUser() } } + } + // The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is + // signed out from another device. + val isSelectedSessionInvalid by remember { + derivedStateOf { + val loadedSessions = sessions ?: return@derivedStateOf false + loadedSessions.none { it.userId == selectedSessionId.value && it.isTokenValid } + } + } + LaunchedEffect(isSelectedSessionInvalid) { + if (isSelectedSessionInvalid) { + // The rooms of an invalid session cannot be used + selectedRooms = persistentListOf() + } + } + val selectedAccount by remember { + derivedStateOf { + // Fallback to a MatrixUser with only the userId until the sessions are loaded + accounts.find { it.userId == selectedSessionId } ?: MatrixUser(selectedSessionId) + } + } + val otherAccounts by remember { + derivedStateOf { + accounts.filter { it.userId != selectedSessionId }.toImmutableList() + } } - val roomSummaryDetailsList by dataSource.roomInfoList.collectAsState(initial = persistentListOf()) + val coroutineScope = rememberCoroutineScope() + // Create one data source per session. When the selected session changes, the previous + // data source is removed from the composition and its coroutine scope is cancelled. + // Also use the validity of the session as a key, so that the data source is removed if the session is invalidated, and recreated + // if the session becomes valid again. + val (dataSource, hasClientError, isSessionNotVerified) = key(selectedSessionId, isSelectedSessionInvalid) { + val sessionId = selectedSessionId + if (isSelectedSessionInvalid) { + // Do not try to get the client of an invalid session: it cannot be restored, and an in-memory client would have been destroyed. + Triple(null, false, false) + } else { + val dataSourceCoroutineScope = rememberCoroutineScope() + // The sessions are restored at startup, so the client should already be in memory. + // A null value means that the client is being restored. + val clientResult by produceState(matrixClientProvider.getOrNull(sessionId)?.let { Result.success(it) }) { + if (value == null) { + value = matrixClientProvider.getOrRestore(sessionId) + .onFailure { Timber.e(it, "Failed to get the client for $sessionId") } + } + } + val client = clientResult?.getOrNull() + val dataSource = remember(client) { + client?.let { dataSourceFactory.create(dataSourceCoroutineScope, it.roomListService) } + } + // Content cannot be shared with a session which is not verified. The initial session is verified, since the FTUE is complete. + val sessionVerifiedStatus by remember(client) { + if (mode == RoomSelectMode.Share && sessionId != initialSessionId && client != null) { + client.sessionVerificationService.sessionVerifiedStatus + } else { + MutableStateFlow(SessionVerifiedStatus.Unknown) + } + }.collectAsState() + Triple(dataSource, clientResult?.isFailure == true, sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) + } + } + val hasRoomListError = hasClientError || isSelectedSessionInvalid + val sessionToVerify = selectedSessionId.takeIf { isSessionNotVerified } + LaunchedEffect(sessionToVerify) { + sessionToVerify?.let(navigator::navigateToSessionVerification) + } - val searchResults by remember>>> { + val searchQuery = queryState.text.toString() + LaunchedEffect(dataSource, searchQuery) { + dataSource?.setSearchQuery(searchQuery) + } + + // Use a key so that the rooms of the previously selected session are not rendered while loading the new ones + val roomSummaryDetailsList by key(dataSource) { + (dataSource?.roomInfoList ?: flowOf(persistentListOf())).collectAsState(initial = persistentListOf()) + } + + val searchResults by remember>>>(dataSource) { derivedStateOf { when { roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) - isSearchActive -> SearchBarResultState.NoResultsFound + queryState.text.isNotBlank() -> SearchBarResultState.NoResultsFound else -> SearchBarResultState.Initial } } @@ -81,9 +171,15 @@ class RoomSelectPresenter( selectedRooms.adding(event.room) } } - RoomSelectEvent.ToggleSearchActive -> isSearchActive = !isSearchActive + RoomSelectEvent.ToggleAccountListExpanded -> isAccountListExpanded = !isAccountListExpanded + is RoomSelectEvent.SelectAccount -> { + selectedSessionId = event.sessionId + // The selected rooms are specific to the session. The search query is kept, and applied to the rooms of the new session. + selectedRooms = persistentListOf() + isAccountListExpanded = false + } is RoomSelectEvent.UpdateVisibleRange -> coroutineScope.launch { - dataSource.updateVisibleRange(event.range) + dataSource?.updateVisibleRange(event.range) } } } @@ -93,8 +189,11 @@ class RoomSelectPresenter( maxNumberOfRooms = maxNumberOfRooms, resultState = searchResults, searchQuery = queryState, - isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, + otherAccounts = otherAccounts, + isAccountListExpanded = isAccountListExpanded, eventSink = ::handleEvent, ) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt index c6e6bc88fc..b17b74e48f 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt @@ -36,12 +36,12 @@ private const val PAGE_SIZE = 30 @AssistedInject class RoomSelectSearchDataSource( @Assisted coroutineScope: CoroutineScope, - roomListService: RoomListService, + @Assisted roomListService: RoomListService, coroutineDispatchers: CoroutineDispatchers, ) { @AssistedFactory interface Factory { - fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource + fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource } private val roomList = roomListService.createRoomList( diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt index aadb7c10d1..7ec5fdf0a6 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectState.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.TextFieldState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectMode import kotlinx.collections.immutable.ImmutableList @@ -19,9 +20,20 @@ data class RoomSelectState( val maxNumberOfRooms: Int, val resultState: SearchBarResultState>, val searchQuery: TextFieldState, - val isSearchActive: Boolean, val selectedRooms: ImmutableList, + val selectedAccount: MatrixUser, + /** + * True if the rooms of the selected account cannot be loaded. + */ + val hasRoomListError: Boolean, + val otherAccounts: ImmutableList, + val isAccountListExpanded: Boolean, val eventSink: (RoomSelectEvent) -> Unit, ) { val canSelectMoreRooms = selectedRooms.size < maxNumberOfRooms + + /** + * The account switch is only displayed when sharing, and if there are several accounts. + */ + val showAccountSwitch = mode == RoomSelectMode.Share && otherAccounts.isNotEmpty() } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt index 41fbf93532..440179e55d 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStatePreviewParam.kt @@ -13,6 +13,8 @@ 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.api.core.UserId +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.aSelectRoomInfo import io.element.android.libraries.matrix.ui.model.SelectRoomInfo @@ -24,17 +26,15 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider get() = sequenceOf( aRoomSelectState(), - aRoomSelectState(searchQuery = "Test", isSearchActive = true), + aRoomSelectState(searchQuery = "Test", resultState = SearchBarResultState.NoResultsFound), aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState( resultState = SearchBarResultState.Results(aRoomSelectRoomList()), searchQuery = "Test", - isSearchActive = true, ), aRoomSelectState( resultState = SearchBarResultState.Results(aRoomSelectRoomList()), searchQuery = "Test", - isSearchActive = true, selectedRooms = aRoomSelectRoomList().subList(0, 1), ), aRoomSelectState( @@ -47,6 +47,25 @@ open class RoomSelectStatePreviewParam : PreviewParameterProvider> = SearchBarResultState.Initial, searchQuery: String = "", - isSearchActive: Boolean = false, selectedRooms: ImmutableList = persistentListOf(), + selectedAccount: MatrixUser = MatrixUser(UserId("@alice:example.org")), + hasRoomListError: Boolean = false, + otherAccounts: ImmutableList = persistentListOf(), + isAccountListExpanded: Boolean = false, eventSink: (RoomSelectEvent) -> Unit = {}, ) = RoomSelectState( mode = mode, maxNumberOfRooms = maxNumberOfRooms, resultState = resultState, searchQuery = TextFieldState(initialText = searchQuery), - isSearchActive = isSearchActive, selectedRooms = selectedRooms, + selectedAccount = selectedAccount, + hasRoomListError = hasRoomListError, + otherAccounts = otherAccounts, + isAccountListExpanded = isAccountListExpanded, eventSink = eventSink, ) +private fun anOtherAccountList() = persistentListOf( + aMatrixUser(id = "@bob:example.org", displayName = "Bob"), + aMatrixUser(id = "@carol:server.org", displayName = "Carol"), +) + internal fun aRoomSelectRoomList() = persistentListOf( aSelectRoomInfo( roomId = RoomId("!room1: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 43b91068f9..dcae5b9f87 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 @@ -9,6 +9,12 @@ package io.element.android.libraries.roomselect.impl import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,41 +25,61 @@ 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.fillMaxSize 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.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow 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.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.TopAppBarScrollBehaviorLayout import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.designsystem.theme.components.SearchField import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -61,6 +87,10 @@ import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffec import io.element.android.libraries.designsystem.utils.lazyColumnContentPadding import io.element.android.libraries.designsystem.utils.scaffoldScrollableContentInsets 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.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.MatrixUserHeader +import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.components.SelectedRoom import io.element.android.libraries.matrix.ui.model.SelectRoomInfo import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -69,8 +99,10 @@ import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=195-38510 + */ @Suppress("MultipleEmitters") // False positive -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RoomSelectView( state: RoomSelectState, @@ -99,10 +131,8 @@ fun RoomSelectView( } var canHandleBack by remember { mutableStateOf(true) } - fun onBackButton(state: RoomSelectState) { - if (state.isSearchActive) { - state.eventSink(RoomSelectEvent.ToggleSearchActive) - } else if (canHandleBack) { + fun onBackButton() { + if (canHandleBack) { canHandleBack = false onDismiss() } @@ -110,10 +140,34 @@ fun RoomSelectView( BackHandler( enabled = canHandleBack, - onBack = { onBackButton(state) } + onBack = ::onBackButton, ) - val lazyListState = rememberLazyListState() + val searchQuery = state.searchQuery.text.toString() + // Use a new list state when the selected account changes, so that the list of rooms is scrolled to the top + val lazyListState = key(state.selectedAccount.userId) { rememberLazyListState() } + // Scroll the list of rooms to the top when the search query changes + LaunchedEffect(lazyListState, searchQuery) { + lazyListState.scrollToItem(0) + } + // Hide the account switch section when the room list is scrolled up, and show it again as soon as it is scrolled down. + // Use a new state when the selected account changes, so that the section is fully displayed again. + val accountSwitchScrollBehavior = key(state.selectedAccount.userId) { + TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + } + // Collapse the account switch section when the user starts searching. It can be displayed again by scrolling down. + val isSearching = searchQuery.isNotBlank() + LaunchedEffect(isSearching) { + if (isSearching) { + val topAppBarState = accountSwitchScrollBehavior.state + animate( + initialValue = topAppBarState.heightOffset, + targetValue = topAppBarState.heightOffsetLimit, + ) { value, _ -> + topAppBarState.heightOffset = value + } + } + } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> state.eventSink(RoomSelectEvent.UpdateVisibleRange(visibleRange)) } @@ -123,13 +177,13 @@ fun RoomSelectView( topBar = { TopAppBar( titleStr = when (state.mode) { - RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) + RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_to) RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to) }, navigationIcon = { BackButton( enabled = canHandleBack, - onClick = { onBackButton(state) } + onClick = ::onBackButton, ) }, actions = { @@ -147,34 +201,67 @@ fun RoomSelectView( Modifier .padding(paddingValues) .consumeWindowInsets(paddingValues) + .then( + // Only when the account switch section is displayed, else its unknown height would consume all the scroll events + if (state.showAccountSwitch) { + Modifier.nestedScroll(accountSwitchScrollBehavior.nestedScrollConnection) + } else { + Modifier + } + ) ) { - SearchBar( + SearchField( + state = state.searchQuery, modifier = Modifier .fillMaxWidth() .padding( WindowInsets.safeDrawing .only(WindowInsetsSides.Horizontal) .asPaddingValues() - ), - placeHolderTitle = stringResource(CommonStrings.action_search), - queryState = state.searchQuery, - active = state.isSearchActive, - onActiveChange = { state.eventSink(RoomSelectEvent.ToggleSearchActive) }, - resultState = state.resultState, - showBackButton = false, - ) { summaries -> - LazyColumn( - state = lazyListState, - contentPadding = lazyColumnContentPadding, - ) { - item { - SelectedRoomsHelper( - selectedRooms = state.selectedRooms, - showVerticalSpace = false, - ) - } - items(summaries, key = { it.roomId.value }) { roomSummary -> - Column { + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = stringResource(CommonStrings.action_search), + ) + if (state.showAccountSwitch) { + // Keep this space outside the collapsing section, so that the section does not touch the search field when it is collapsing + Spacer(modifier = Modifier.height(8.dp)) + TopAppBarScrollBehaviorLayout(scrollBehavior = accountSwitchScrollBehavior) { + AccountSwitchSection( + selectedAccount = state.selectedAccount, + otherAccounts = state.otherAccounts, + isExpanded = state.isAccountListExpanded, + onToggleExpand = { state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) }, + onSelectAccount = { state.eventSink(RoomSelectEvent.SelectAccount(it)) }, + ) + } + } + SelectedRoomsHelper( + selectedRooms = state.selectedRooms, + // showVerticalSpace only if there is no other accounts + showVerticalSpace = !state.showAccountSwitch, + ) + when { + state.hasRoomListError -> RoomListError() + state.resultState is SearchBarResultState.NoResultsFound -> NoResultsView(searchQuery = searchQuery) + state.resultState is SearchBarResultState.Results -> { + LazyColumn( + state = lazyListState, + contentPadding = lazyColumnContentPadding, + ) { + if (state.resultState.results.isNotEmpty()) { + item { + val headerText = if (searchQuery.isBlank()) { + stringResource(CommonStrings.common_header_rooms) + } else { + stringResource(CommonStrings.common_results_for, searchQuery) + } + ListSectionHeader( + title = headerText, + hasDivider = false, + ) + } + } + items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> RoomSummaryView( roomSummary, isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, @@ -183,39 +270,153 @@ fun RoomSelectView( }, canBeSelected = state.canSelectMoreRooms, ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) } } } + else -> Unit } + } + } +} - if (!state.isSearchActive) { - SelectedRoomsHelper( - selectedRooms = state.selectedRooms, - showVerticalSpace = true, - ) - if (state.resultState is SearchBarResultState.Results) { - LazyColumn( - state = lazyListState, - contentPadding = lazyColumnContentPadding, +@Composable +private fun NoResultsView( + searchQuery: String, +) { + Column( + modifier = Modifier + .fillMaxSize() + // Scrollable, so that the account switch section can be displayed again by scrolling down + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(CommonStrings.common_no_results), + modifier = Modifier + .semantics { + heading() + }, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontHeadingMdBold, + color = ElementTheme.colors.textPrimary, + ) + Text( + text = stringResource(CommonStrings.common_no_results_for, searchQuery), + modifier = Modifier.widthIn(max = 300.dp), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + } +} + +@Composable +private fun RoomListError() { + IconTitleSubtitleMolecule( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 32.dp), + title = stringResource(CommonStrings.common_something_went_wrong), + subTitle = stringResource(R.string.screen_room_select_error_cannot_load_rooms), + iconStyle = BigIcon.Style.AlertSolid, + ) +} + +/** + * Similar to the MultiAccountSection of the PreferencesRootView. + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=5414-4759 + */ +@Composable +private fun AccountSwitchSection( + selectedAccount: MatrixUser, + otherAccounts: ImmutableList, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + onSelectAccount: (SessionId) -> Unit, +) { + Column { + MatrixUserHeader( + matrixUser = selectedAccount, + ) + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + val expandedStateDescription = if (isExpanded) { + stringResource(CommonStrings.a11y_state_expanded) + } else { + stringResource(CommonStrings.a11y_state_collapsed) + } + ListItem( + modifier = Modifier.semantics { + stateDescription = expandedStateDescription + }, + content = { Text(stringResource(CommonStrings.common_switch_account)) }, + onClick = onToggleExpand, + trailingContent = ListItemContent.Custom { _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = !isExpanded, + enter = fadeIn(), + exit = fadeOut(), ) { - items(state.resultState.results, key = { it.roomId.value }) { roomSummary -> - Column { - RoomSummaryView( - roomSummary, - isSelected = state.selectedRooms.any { it.roomId == roomSummary.roomId }, - onSelection = { roomSummary -> - state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary)) - }, - canBeSelected = state.canSelectMoreRooms, - ) - HorizontalDivider(modifier = Modifier.fillMaxWidth()) - } - } + AvatarRow( + avatarDataList = otherAccounts + .take(3) + .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .toImmutableList(), + avatarType = AvatarType.User, + lastOnTop = true, + ) } + // Animate the chevron icon to rotate when the section is expanded/collapsed + val rotation: Float by animateFloatAsState( + targetValue = if (isExpanded) -180f else 0f, + animationSpec = tween( + delayMillis = 0, + durationMillis = 300, + ), + label = "chevron" + ) + Icon( + modifier = Modifier.rotate(rotation), + imageVector = CompoundIcons.ChevronDown(), + contentDescription = null, + ) + } + }, + ) + AnimatedVisibility( + visible = isExpanded, + ) { + Column { + HorizontalDivider( + thickness = 1.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) + otherAccounts.forEach { matrixUser -> + MatrixUserRow( + modifier = Modifier + .clickable { + onSelectAccount(matrixUser.userId) + } + .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), + matrixUser = matrixUser, + avatarSize = AvatarSize.AccountItem, + verticalSpaceWidth = 16.dp, + ) } } } + HorizontalDivider( + thickness = 8.dp, + color = ElementTheme.colors.bgSubtleSecondary, + ) } } @@ -243,28 +444,25 @@ private fun RoomSummaryView( canBeSelected: Boolean, onSelection: (SelectRoomInfo) -> Unit, ) { - Row( - modifier = Modifier - .clickable { onSelection(roomInfo) } - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp) - .heightIn(56.dp), - verticalAlignment = Alignment.CenterVertically + ListItem( + onClick = { onSelection(roomInfo) }, + leadingContent = ListItemContent.Custom { + Avatar( + avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), + avatarType = AvatarType.Room( + heroes = roomInfo.heroes.map { user -> + user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) + }.toImmutableList(), + isTombstoned = roomInfo.isTombstoned, + ), + ) + }, + trailingContent = ListItemContent.Checkbox( + checked = isSelected, + enabled = isSelected || canBeSelected, + ), ) { - Avatar( - avatarData = roomInfo.getAvatarData(size = AvatarSize.RoomSelectRoomListItem), - avatarType = AvatarType.Room( - heroes = roomInfo.heroes.map { user -> - user.getAvatarData(size = AvatarSize.RoomSelectRoomListItem) - }.toImmutableList(), - isTombstoned = roomInfo.isTombstoned, - ), - ) - Column( - modifier = Modifier - .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp) - .weight(1f) - ) { + Column { // Name Text( style = ElementTheme.typography.fontBodyLgRegular, @@ -286,11 +484,6 @@ private fun RoomSummaryView( ) } } - Checkbox( - checked = isSelected, - enabled = isSelected || canBeSelected, - onCheckedChange = { onSelection(roomInfo) }, - ) } } diff --git a/libraries/roomselect/impl/src/main/res/values-hr/translations.xml b/libraries/roomselect/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 0000000000..0883e6f313 --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,4 @@ + + + "Popis soba povezan s ovim računom ne može se učitati." + diff --git a/libraries/roomselect/impl/src/main/res/values-hu/translations.xml b/libraries/roomselect/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 0000000000..45f086b903 --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,4 @@ + + + "A fiókhoz társított szobalista nem tölthető be." + diff --git a/libraries/roomselect/impl/src/main/res/values-pl/translations.xml b/libraries/roomselect/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 0000000000..c97957be6c --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,4 @@ + + + "Nie można wczytać listy pokoi powiązanej z tym kontem." + diff --git a/libraries/roomselect/impl/src/main/res/values-uk/translations.xml b/libraries/roomselect/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 0000000000..97f659934a --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,4 @@ + + + "Не вдалося завантажити список кімнат, пов\'язаний з цим обліковим записом." + diff --git a/libraries/roomselect/impl/src/main/res/values-zh/translations.xml b/libraries/roomselect/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..527561826b --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,4 @@ + + + "无法加载与此账户关联的房间列表。" + diff --git a/libraries/roomselect/impl/src/main/res/values/localazy.xml b/libraries/roomselect/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..377391b835 --- /dev/null +++ b/libraries/roomselect/impl/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "The room list associated with this account cannot be loaded." + diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt index 6863c1ce20..229e6bd3b0 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/DefaultRoomSelectEntryPointTest.kt @@ -12,6 +12,8 @@ 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.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.tests.testutils.lambda.lambdaError @@ -36,18 +38,21 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { RoomSelectNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { mode, maxNumberOfRooms -> + presenterFactory = { initialSessionId, mode, maxNumberOfRooms, _ -> + assertThat(initialSessionId).isEqualTo(A_SESSION_ID) assertThat(mode).isEqualTo(testMode) assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms) - createRoomSelectPresenter(mode, maxNumberOfRooms) + createRoomSelectPresenter(mode = mode, maxNumberOfRooms = maxNumberOfRooms) }, ) } val callback = object : RoomSelectEntryPoint.Callback { - override fun onRoomSelected(roomIds: List) = lambdaError() + override fun onRoomSelected(sessionId: SessionId, roomIds: List) = lambdaError() override fun onCancel() = lambdaError() + override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError() } val params = RoomSelectEntryPoint.Params( + sessionId = A_SESSION_ID, mode = testMode, maxNumberOfRooms = testMaxNumberOfRooms, ) @@ -58,7 +63,7 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() { callback = callback, ) assertThat(result).isInstanceOf(RoomSelectNode::class.java) - assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.mode, params.maxNumberOfRooms)) + assertThat(result.plugins).contains(RoomSelectNode.Inputs(params.sessionId, params.mode, params.maxNumberOfRooms)) assertThat(result.plugins).contains(callback) } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index b5a4ccfd12..91e26be182 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -9,19 +9,39 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import app.cash.turbine.Event import com.google.common.truth.Truth.assertThat import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.user.MatrixUser +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_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +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.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode +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.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +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 import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf @@ -43,24 +63,355 @@ class RoomSelectPresenterTest { val initialState = awaitItem() assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) - assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.maxNumberOfRooms).isEqualTo(10) assertThat(initialState.canSelectMoreRooms).isTrue() + assertThat(initialState.selectedAccount).isEqualTo(MatrixUser(A_SESSION_ID)) + assertThat(initialState.otherAccounts).isEmpty() + assertThat(initialState.isAccountListExpanded).isFalse() + assertThat(initialState.showAccountSwitch).isFalse() + assertThat(initialState.hasRoomListError).isFalse() } } @Test - fun `present - toggle search active`() = runTest { - val presenter = createRoomSelectPresenter() + fun `present - failure to get the client shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + matrixClientProvider = FakeMatrixClientProvider { Result.failure(AN_EXCEPTION) }, + ) presenter.test { - val initialState = awaitItem() - initialState.eventSink(RoomSelectEvent.ToggleSearchActive) - assertThat(awaitItem().isSearchActive).isTrue() - initialState.eventSink(RoomSelectEvent.ToggleSearchActive) - assertThat(awaitItem().isSearchActive).isFalse() + val state = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(state.hasRoomListError).isTrue() + assertThat(state.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) } } + @Test + fun `present - select another account whose client cannot be restored shows an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) { + Result.success(FakeMatrixClient(sessionId = sessionId)) + } else { + Result.failure(AN_EXCEPTION) + } + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + // Going back to the first account clears the error + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.hasRoomListError).isFalse() + } + } + + @Test + fun `present - share mode with a single account does not show the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)), + ), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(state.otherAccounts).isEmpty() + assertThat(state.showAccountSwitch).isFalse() + } + } + + @Test + fun `present - share mode with several accounts shows the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(state.selectedAccount.displayName).isEqualTo("Alice") + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(state.showAccountSwitch).isTrue() + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + assertThat(awaitItem().isAccountListExpanded).isTrue() + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + assertThat(awaitItem().isAccountListExpanded).isFalse() + } + } + + @Test + fun `present - accounts with an invalid token are displayed, selecting one displays an error`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = false), + ), + ), + // The client of the invalid session must not be requested + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + if (sessionId == A_SESSION_ID) Result.success(FakeMatrixClient(sessionId = sessionId)) else lambdaError() + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(state.hasRoomListError).isFalse() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val errorState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 && it.hasRoomListError }.last() + // The account information is still available + assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob") + assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) + } + } + + @Test + fun `present - the selected account is invalidated, an error is displayed and the selected rooms are cleared`() = runTest { + val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }), + ) + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val otherAccountState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && + (it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2) + }.last() + otherAccountState.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary2.toSelectRoomInfo())) + assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1) + // The session of the selected account is invalidated, for instance signed out from another device + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + assertThat(errorState.selectedAccount.displayName).isEqualTo("Bob") + assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + // Go back to the current account: the invalid account is still proposed + errorState.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID)) + val finalState = consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID && !it.hasRoomListError }.last() + assertThat(finalState.showAccountSwitch).isTrue() + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + } + } + + @Test + fun `present - the current account is invalidated while selected, an error is displayed`() = runTest { + val roomSummary = aRoomSummary() + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + roomListService = FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary))) }), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last() + state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary.toSelectRoomInfo())) + assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1) + // The current session is signed out from another device while the screen is displayed + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID.value)!!.copy(isTokenValid = false)) + val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last() + assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) + // The user can switch to the other account + assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest { + val sessionStore = aSessionStoreWithTwoAccounts() + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = sessionStore, + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + sessionStore.updateData(sessionStore.getSession(A_SESSION_ID_2.value)!!.copy(isTokenValid = false)) + testScheduler.advanceUntilIdle() + // The state may not change at all + val finalState = cancelAndConsumeRemainingEvents().filterIsInstance>().lastOrNull()?.value ?: state + assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2) + assertThat(finalState.hasRoomListError).isFalse() + assertThat(finalState.showAccountSwitch).isTrue() + } + } + + @Test + fun `present - select another account displays the rooms of this account`() = runTest { + val roomSummary1 = aRoomSummary(roomId = A_ROOM_ID) + val roomSummary2 = aRoomSummary(roomId = A_ROOM_ID_2) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary1))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary2))) }), + ) + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + // Wait for the rooms to be loaded, they are emitted on another dispatcher + val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last() + assertThat(state.selectedAccount.userId).isEqualTo(A_SESSION_ID) + assertThat((state.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID) + state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary1.toSelectRoomInfo())) + state.eventSink(RoomSelectEvent.ToggleAccountListExpanded) + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val finalState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && + (it.resultState as? SearchBarResultState.Results)?.results?.map { room -> room.roomId } == listOf(A_ROOM_ID_2) + }.last() + assertThat(finalState.selectedAccount.userId).isEqualTo(A_SESSION_ID_2) + assertThat(finalState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID) + assertThat(finalState.selectedRooms).isEmpty() + assertThat(finalState.isAccountListExpanded).isFalse() + assertThat((finalState.resultState as SearchBarResultState.Results).results.map { it.roomId }).containsExactly(A_ROOM_ID_2) + } + } + + @Test + fun `present - select another account which is not verified navigates to its verification`() = runTest { + val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified) + val navigateToSessionVerificationLambda = lambdaRecorder { } + val presenter = createRoomSelectPresenter( + navigator = RoomSelectNavigator(navigateToSessionVerificationLambda), + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success( + FakeMatrixClient( + sessionId = sessionId, + sessionVerificationService = if (sessionId == A_SESSION_ID_2) sessionVerificationService else FakeSessionVerificationService(), + ) + ) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 } + testScheduler.advanceUntilIdle() + navigateToSessionVerificationLambda.assertions().isCalledOnce().with(value(A_SESSION_ID_2)) + // The session is verified, no new navigation + sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified) + testScheduler.advanceUntilIdle() + navigateToSessionVerificationLambda.assertions().isCalledOnce() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - the initial session is not verified, no navigation to its verification`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success( + FakeMatrixClient( + sessionId = sessionId, + sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified), + ) + ) + }, + ) + presenter.test { + // The default navigator fails if it is invoked + awaitLastSequentialItem() + testScheduler.advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - forward mode, no navigation to the verification of the session`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Forward, + initialSessionId = A_SESSION_ID_2, + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success( + FakeMatrixClient( + sessionId = sessionId, + sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified), + ) + ) + }, + ) + presenter.test { + // The default navigator fails if it is invoked + awaitLastSequentialItem() + testScheduler.advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest { + val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2)))) + val roomListServices = mapOf( + A_SESSION_ID to FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary()))) }), + A_SESSION_ID_2 to FakeRoomListService(createRoomListLambda = { roomList2 }), + ) + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Share, + sessionStore = aSessionStoreWithTwoAccounts(), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListServices.getValue(sessionId))) + }, + ) + presenter.test { + val state = awaitLastSequentialItem() + state.searchQuery.setTextAndPlaceCursorAtEnd("query") + state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2)) + val finalState = consumeItemsUntilPredicate { + it.selectedAccount.userId == A_SESSION_ID_2 && it.resultState is SearchBarResultState.Results + }.last() + assertThat(finalState.searchQuery.text.toString()).isEqualTo("query") + assertThat(finalState.showAccountSwitch).isTrue() + assertThat(roomList2.currentFilter.value).isEqualTo(RoomListFilter.NormalizedMatchRoomName("query")) + } + } + + @Test + fun `present - forward mode with several accounts does not show the account switch`() = runTest { + val presenter = createRoomSelectPresenter( + mode = RoomSelectMode.Forward, + sessionStore = aSessionStoreWithTwoAccounts(), + ) + presenter.test { + val state = awaitLastSequentialItem() + assertThat(state.otherAccounts).isNotEmpty() + assertThat(state.showAccountSwitch).isFalse() + } + } + + private fun aSessionStoreWithTwoAccounts() = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_SESSION_ID.value, userDisplayName = "Alice", isTokenValid = true), + aSessionData(sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", isTokenValid = true), + ), + ) + @Test fun `present - update query`() = runTest { val roomSummary = aRoomSummary() @@ -79,8 +430,6 @@ class RoomSelectPresenterTest { // Do not compare the lambda because they will be different. So copy the lambda from expectedRoomSummary to result val result = (awaitItem().resultState as SearchBarResultState.Results).results assertThat(result).isEqualTo(listOf(expectedRoomInfo)) - initialState.eventSink(RoomSelectEvent.ToggleSearchActive) - skipItems(1) initialState.searchQuery.setTextAndPlaceCursorAtEnd("string not contained") assertThat( roomList.currentFilter.value @@ -154,19 +503,32 @@ class RoomSelectPresenterTest { } internal fun TestScope.createRoomSelectPresenter( + initialSessionId: SessionId = A_SESSION_ID, mode: RoomSelectMode = RoomSelectMode.Forward, maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS, roomListService: RoomListService = FakeRoomListService(), + // The current session is always in the store + sessionStore: SessionStore = InMemorySessionStore( + initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)), + ), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId -> + Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService)) + }, + navigator: RoomSelectNavigator = RoomSelectNavigator { lambdaError() }, ) = RoomSelectPresenter( + initialSessionId = initialSessionId, mode = mode, maxNumberOfRooms = maxNumberOfRooms, + navigator = navigator, dataSourceFactory = object : RoomSelectSearchDataSource.Factory { - override fun create(coroutineScope: CoroutineScope): RoomSelectSearchDataSource { + override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource { return RoomSelectSearchDataSource( coroutineScope = coroutineScope, roomListService = roomListService, coroutineDispatchers = testCoroutineDispatchers(), ) } - } + }, + matrixClientProvider = matrixClientProvider, + sessionStore = sessionStore, ) 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 index db0219bb04..3f60508816 100644 --- 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 @@ -13,16 +13,27 @@ 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.assertIsNotDisplayed import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp 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.api.core.UserId import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo +import io.element.android.libraries.roomselect.api.RoomSelectMode +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.robolectric.RobolectricTest import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.junit.Test import org.robolectric.annotation.Config @@ -72,6 +83,101 @@ class RoomSelectViewTest : RobolectricTest() { onNodeWithText("Room with a single hero").assertIsDisplayed() onNodeWithText("@alice:example.org").assertDoesNotExist() } + + @Test + fun `clicking on switch account emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + eventSink = eventsRecorder, + ), + ) + clickOn(CommonStrings.common_switch_account) + // The first event is the initial UpdateVisibleRange + eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.ToggleAccountListExpanded)) + } + + @Test + fun `clicking on another account emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + isAccountListExpanded = true, + eventSink = eventsRecorder, + ), + ) + onNodeWithText("Bob").performClick() + // The first event is the initial UpdateVisibleRange + eventsRecorder.assertList(listOf(RoomSelectEvent.UpdateVisibleRange(0..-1), RoomSelectEvent.SelectAccount(UserId("@bob:example.org")))) + } + + @Test + fun `the account switch is not displayed if there is no other account`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + selectedAccount = aMatrixUser(id = "@alice:example.org", displayName = "Alice"), + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_switch_account)).assertDoesNotExist() + } + + @Test + fun `the room list can be scrolled when there is no other account`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aLongRoomList()), + ), + ) + onNodeWithText("Room 0").assertIsDisplayed() + onRoot().performTouchInput { swipeUp() } + onNodeWithText("Room 0").assertIsNotDisplayed() + } + + @Test + fun `the room list can be scrolled when there are other accounts`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aLongRoomList()), + otherAccounts = persistentListOf(aMatrixUser(id = "@bob:example.org", displayName = "Bob")), + ), + ) + onNodeWithText("Room 0").assertIsDisplayed() + onRoot().performTouchInput { swipeUp() } + onNodeWithText("Room 0").assertIsNotDisplayed() + } + + @Test + fun `a message is displayed when no room matches the search query`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + searchQuery = "Test", + resultState = SearchBarResultState.NoResultsFound, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_no_results)).assertIsDisplayed() + } + + @Test + fun `an error is displayed instead of the rooms if they cannot be loaded`() = runAndroidComposeUiTest { + setRoomSelectView( + aRoomSelectState( + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + hasRoomListError = true, + ), + ) + onNodeWithText(activity!!.getString(CommonStrings.common_something_went_wrong)).assertIsDisplayed() + onNodeWithText(activity!!.getString(R.string.screen_room_select_error_cannot_load_rooms)).assertIsDisplayed() + onNodeWithText("Room with alias").assertDoesNotExist() + } } private fun AndroidComposeUiTest.setRoomSelectView( @@ -87,3 +193,10 @@ private fun AndroidComposeUiTest.setRoomSelectView( ) } } + +private fun aLongRoomList() = List(30) { index -> + aSelectRoomInfo( + roomId = RoomId("!room$index:domain"), + name = "Room $index", + ) +}.toImmutableList() diff --git a/libraries/rustls-tls/README.md b/libraries/rustls-tls/README.md deleted file mode 100644 index aa45fe6743..0000000000 --- a/libraries/rustls-tls/README.md +++ /dev/null @@ -1,9 +0,0 @@ -This module is a wrapper for the Android code distributed in the rustls-platform-verifier-android crate. - -To avoid the distribution mess that this library has (download a Rust crate, then search for it using Gradle and use it as local maven repo), -we previously just manually updated the AAR file instead using a script. This won't work for F-Droid because the AAR library is a black box with -no sources attached to it, so we can't use it like that. - -Instead, for the time being, we're adding the single `CertificateVerifier.kt` class this AAR had in it as part of our sources. - -When this file is updated, the [UPDATED.md](./UPDATED.md) file should be updated too with the commit SHA of the new version. diff --git a/libraries/rustls-tls/UPDATED.md b/libraries/rustls-tls/UPDATED.md deleted file mode 100644 index 10dba2dd53..0000000000 --- a/libraries/rustls-tls/UPDATED.md +++ /dev/null @@ -1,7 +0,0 @@ -Below is the commit SHA in [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) library used to update the code in this module: - -``` -996b1c903491641b17b3c9afb65d1352f6fc6b76 -``` - -Please update it after making manual changes. diff --git a/libraries/rustls-tls/build.gradle.kts b/libraries/rustls-tls/build.gradle.kts deleted file mode 100644 index 85f3f4c476..0000000000 --- a/libraries/rustls-tls/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -import extension.buildConfigFieldBoolean - -/* - * 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. - */ - -plugins { - id("io.element.android-library") -} - -android { - namespace = "org.rustls.platformverifier" - - buildFeatures { - buildConfig = true - } - - defaultConfig { - buildConfigFieldBoolean("TEST", false) - } -} diff --git a/libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt b/libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt deleted file mode 100644 index b70413ffe5..0000000000 --- a/libraries/rustls-tls/src/main/kotlin/org/rustls/platformverifier/CertificateVerifier.kt +++ /dev/null @@ -1,398 +0,0 @@ -@file:SuppressLint("LogNotTimber", "ObsoleteSdkInt") -@file:Suppress("KotlinConstantConditions") - -// IMPORTANT: this file comes from rustls-platform-verifier and should not be modified locally. - -/* - * Copyright (c) 2022 1Password - * - * SPDX-License-Identifier: MIT - */ - -package org.rustls.platformverifier - -import android.annotation.SuppressLint -import android.content.Context -import android.net.http.X509TrustManagerExtensions -import android.util.Log -import java.io.ByteArrayInputStream -import java.io.File -import java.security.KeyStore -import java.security.KeyStoreException -import java.security.MessageDigest -import java.security.PublicKey -import java.security.cert.CertificateException -import java.security.cert.CertificateExpiredException -import java.security.cert.CertificateFactory -import java.security.cert.CertificateNotYetValidException -import java.security.cert.CertificateParsingException -import java.security.cert.X509Certificate -import java.util.Date -import javax.net.ssl.TrustManagerFactory -import javax.net.ssl.X509TrustManager -import javax.security.auth.x500.X500Principal - -// If this is updated, update the Rust definition too. -// Marked private as this is not meant to be used in Android code. -private enum class StatusCode(val value: Int) { - Ok(0), - Unavailable(1), - Expired(2), - UnknownCert(3), - Revoked(4), - InvalidEncoding(5), - InvalidExtension(6), -} - -// Marked private as this is not meant to be used in Android code. -private class VerificationResult( - status: StatusCode, - @Suppress("unused") val message: String? = null -) { - @Suppress("unused") - private val code: Int = status.value -} - -// NOTE: All TrustManager and certificate validation methods are not thread safe. These -// are all guarded by Kotlin's `Synchronized` accessors to prevent undefined behavior. - -// Only JNI and test code calls this, so unused code warnings are suppressed. -// Internal for test code - no other Kotlin code should use this object directly. -@Suppress("unused") -// We want to show a difference between Kotlin-side logs and those in Rust code -@SuppressLint("LongLogTag") -internal object CertificateVerifier { - private const val TAG = "rustls-platform-verifier-android" - - private fun createTrustManager(keystore: KeyStore?): X509TrustManagerExtensions? { - // This can never throw since the default algorithm is used. - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - - factory.init(keystore) - - val availableTrustManagers = try { - factory.trustManagers - } catch (e: RuntimeException) { - Log.w(TAG, "exception thrown creating a TrustManager: $e") - return null - } - - for (manager in availableTrustManagers) { - if (manager is X509TrustManager) { - // Kotlin ensures this can't throw at runtime since it knows that - // it must be the correct type by now. - return X509TrustManagerExtensions(manager) - } - } - - Log.e(TAG, "failed to find a usable trust manager") - return null - } - - private fun makeLazyTrustManager(keystore: KeyStore?): Lazy { - // Ensure the keystore is loaded. Since all of the trust managers are initialized in a - // `Lazy`, this will only run once. - keystore?.load(null) - - return lazy { createTrustManager(keystore) } - } - - // -- Test only -- - // Ideally, all of this will be optimized out at compile time due to not being accessed - // in release builds. - - @get:Synchronized - private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType()) - - @get:Synchronized - private var mockTrustManager: Lazy = - makeLazyTrustManager(mockKeystore) - - @JvmStatic - private fun addMockRoot(root: ByteArray) { - if (!BuildConfig.TEST) { - throw Exception("attempted to add a mock root outside a test!") - } - - val alias = "root_${mockKeystore.size()}" - // Throwing here is fine since test roots should always be well-formed - val cert = certFactory.generateCertificate(ByteArrayInputStream(root)) - mockKeystore.setCertificateEntry(alias, cert) - - reloadMockData() - } - - @JvmStatic - private fun clearMockRoots() { - // Reload to get a completely fresh internal state - mockKeystore.load(null) - reloadMockData() - } - - @JvmStatic - private fun reloadMockData() { - if (mockTrustManager.isInitialized()) { - mockTrustManager = makeLazyTrustManager(mockKeystore) - } - } - - // Get a list of the system's root CAs. - // Function is public for testing only. - @JvmStatic - fun getSystemRootCAs(): List { - val rootCAs = mutableListOf() - - val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) - factory.init(systemKeystore) - - val availableTrustManagers = try { - factory.trustManagers - } catch (e: RuntimeException) { - Log.w(TAG, "exception thrown creating a TrustManager: $e") - return rootCAs - } - - availableTrustManagers.forEach { trustManager -> - if (trustManager is X509TrustManager) { - rootCAs.addAll(trustManager.acceptedIssuers) - } - } - - return rootCAs - } - - // -- End testing requirements -- - - private val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509") - - private var systemTrustAnchorCache = hashSetOf>() - - @get:Synchronized - private var systemCertificateDirectory: File? = System.getenv("ANDROID_ROOT")?.let { rootPath -> - File("$rootPath/etc/security/cacerts") - } - - @get:Synchronized - private val systemKeystore: KeyStore? = try { - KeyStore.getInstance("AndroidCAStore") - } catch (_: KeyStoreException) { - null - } - - @get:Synchronized - private val systemTrustManager: Lazy = - makeLazyTrustManager(systemKeystore) - - @JvmStatic - private fun verifyCertificateChain( - @Suppress("UNUSED_PARAMETER") context: Context, - serverName: String, - authMethod: String, - allowedEkus: Array, - ocspResponse: ByteArray?, - time: Long, - certChain: Array - ): VerificationResult { - // Convert the array of (supposedly) DER bytes into certificates. - val certificateChain = mutableListOf() - certChain.forEach { certBytes -> - val certificate = try { - certFactory.generateCertificate(ByteArrayInputStream(certBytes)) - } catch (e: CertificateException) { - return VerificationResult(StatusCode.InvalidEncoding) - } - certificateChain.add(certificate as X509Certificate) - } - - // Will never throw `ArrayIndexOutOfBoundsException` because `rustls`'s `ServerCertVerifier` trait - // has a mandatory `end_entity` parameter in `verify_server_cert`. - val endEntity = certificateChain[0] - - // Check that the certificate is valid at the point of time provided by `rustls`. - try { - endEntity.checkValidity(Date(time)) - } catch (e: CertificateExpiredException) { - return VerificationResult(StatusCode.Expired) - } catch (e: CertificateNotYetValidException) { - return VerificationResult(StatusCode.Expired) - } - - // Check that this certificate can be used in a TLS server. - if (!verifyCertUsage(endEntity, allowedEkus)) { - return VerificationResult(StatusCode.InvalidExtension) - } - - // Select the trust manager to use. - // - // We select them as follows: - // - If built for release, only use the system trust manager. This should let all test-related - // code be optimized out. - // - If built for tests: - // - If the mock CA store has any values, use the mock trust manager. - // - Otherwise, use the system trust manager. - val (trustManager, keystore) = if (!BuildConfig.TEST) { - val trustManager = - systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable) - Pair(trustManager, systemKeystore) - } else { - if (mockKeystore.size() != 0) { - val trustManager = mockTrustManager.value!! - Pair(trustManager, mockKeystore) - } else { - val trustManager = - systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable) - Pair(trustManager, systemKeystore) - } - } - - // Verify that the certificate chain is valid and correct, and nothing more. - // - // NOTE: This does not validate `serverName` is valid for the end-entity certificate. - // That is handled in Rust as Android/Java do not currently provide a RFC 6125 compliant - // hostname verifier. Additionally, even the RFC 2818 verifier is not available until API 24. - // - // `serverName` is only used for pinning/CT requirements. - // - // Returns the "the properly ordered chain used for verification as a list of X509Certificates.", - // meaning a list from end-entity certificate to trust-anchor. - val validChain = try { - trustManager.checkServerTrusted(certificateChain.toTypedArray(), authMethod, serverName) - } catch (e: CertificateException) { - // In test configurations we may see `checkServerTrusted` fail once vendored test - // certificates pass their expiry date. We try to avoid that by using a fixed - // verification time when calling `endEntity.checkValidity` above, however we can't - // fix the time for the `checkServerTrusted` call. - // - // To make diagnosing CI test failures easier we try to find the root cause of - // checkServerTrusted failing, returning a different `StatusCode` as appropriate. - if (BuildConfig.TEST) { - var rootCause: Throwable? = e - while (rootCause?.cause != null && rootCause.cause != rootCause) { - rootCause = rootCause.cause - } - return when (rootCause) { - is CertificateExpiredException, is CertificateNotYetValidException -> VerificationResult( - StatusCode.Expired, - rootCause.toString() - ) - - else -> VerificationResult(StatusCode.UnknownCert, rootCause.toString()) - } - } - // In non-test configurations we should have caught expiry errors earlier and - // can simply return an unknown cert error without digging through the exception - // cause chain. - return VerificationResult(StatusCode.UnknownCert, e.toString()) - } - - // TEST ONLY: Mock test suite cannot attempt to check revocation status if no OSCP data has been stapled, - // because Android requires certificates to an specify OCSP responder for network fetch in this case. - // If in testing w/o OCSP stapled, short-circuit here - only prior checks apply. - if (BuildConfig.TEST && (mockKeystore.size() != 0) && (ocspResponse == null)) { - return VerificationResult(StatusCode.Ok) - } - - // ================================================================================================= - // IMPORTANT! DIFF WITH THE ORIGINAL SOURCES HERE: - // - // We removed the cert revocation checks because Android won't do them by default - // and supporting them would be quite difficult given CRLs won't work out of the box. - // - // See https://github.com/rustls/rustls-platform-verifier/issues/221 for more info. - // ================================================================================================= - - return VerificationResult(StatusCode.Ok) - } - - private fun verifyCertUsage(certificate: X509Certificate, allowedEkus: Array): Boolean { - val ekus = try { - certificate.extendedKeyUsage - } - // This should be unreachable, but could happen. - catch (_: CertificateParsingException) { - return false - } catch (_: NullPointerException) { - // According to Chromium's implementation, this can crash when the EKU data is malformed. - Log.w(TAG, "exception handling certificate EKU") - return false - } ?: return true // If the list is empty, we have nothing to do. - - return ekus.any { allowedEkus.contains(it) } - } - - // Android hashes a principal using the first four bytes of its MD5 digest, encoded in - // lowercase hex and reversed. - // - // Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=339 - private fun hashPrincipal(principal: X500Principal): String { - val hexDigits = "0123456789abcdef".toCharArray() - val digest = MessageDigest.getInstance("MD5").digest(principal.encoded) - val hexChars = CharArray(8) - - for (i in 0..3) { - // Kotlin doesn't support bitwise operators for bytes, only Int and Long. - val digestByte = digest[3 - i].toInt() - hexChars[2 * i] = hexDigits[(digestByte shr 4) and 0xf] - hexChars[2 * i + 1] = hexDigits[digestByte and 0xf] - } - - return String(hexChars) - } - - // Check if CA root is known or not. - // Known means installed in root CA store, either a preset public CA or a custom one installed by an enterprise/user. - // - // Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=351 - fun isKnownRoot(root: X509Certificate): Boolean { - // System keystore and cert directory must be non-null to perform checking - systemKeystore?.let { loadedSystemKeystore -> - systemCertificateDirectory?.let { loadedSystemCertificateDirectory -> - - // Check the in-memory cache first - val key = Pair(root.subjectX500Principal, root.publicKey) - if (systemTrustAnchorCache.contains(key)) { - return true - } - - // System trust anchors are stored under a hash of the principal. - // In case of collisions, append number. - val hash = hashPrincipal(root.subjectX500Principal) - var i = 0 - while (true) { - val alias = "$hash.$i" - - if (!File(loadedSystemCertificateDirectory, alias).exists()) { - break - } - - val anchor = loadedSystemKeystore.getCertificate("system:$alias") - - // It's possible for `anchor` to be `null` if the user deleted a trust anchor. - // Continue iterating as there may be further collisions after the deleted anchor. - if (anchor == null) { - continue - // This should never happen - } else if (anchor !is X509Certificate) { - // SAFETY: This logs a unique identifier (hash value) only in cases where a file within the - // system's root trust store is not a valid X509 certificate (extremely unlikely error). - // The hash doesn't tell us any sensitive information about the invalid cert or reveal any of - // its contents - it just lets us ID the bad file if a user is having TLS failure issues. - Log.e(TAG, "anchor is not a certificate, alias: $alias") - continue - // If subject and public key match, it's a system root. - } else { - if ((root.subjectX500Principal == anchor.subjectX500Principal) && (root.publicKey == anchor.publicKey)) { - systemTrustAnchorCache.add(key) - return true - } - } - - i += 1 - } - } - } - - // Not found in cache or store: non-public - return false - } -} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index 88cf9558b3..fa4e22232d 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -46,4 +46,6 @@ data class SessionData( val userDisplayName: String?, /** The optional avatar URL of the user. */ val userAvatarUrl: String?, + /** The optional thumbnail of the avatar of the user ([userAvatarUrl]), base64 encoded. */ + val userAvatarData: String?, ) 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 641a2afdf2..a84a1f00dc 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 @@ -51,8 +51,9 @@ interface SessionStore { * @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. + * @param avatarData the thumbnail of the new avatar, base64 encoded, or `null` when the user has none or when it could not be loaded. */ - suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) + suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) /** * Get the session data matching the userId, or null if not found. diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 8b7d9d6e15..9d00237239 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.sessionStorage.api) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 9be59e4bae..304a969f9d 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber @SingleIn(AppScope::class) @@ -51,83 +52,93 @@ class DatabaseSessionStore( } override suspend fun addSession(sessionData: SessionData) { - sessionDataMutex.withLock { - val lastUsageIndex = getLastUsageIndex() - database.sessionDataQueries.insertSessionData( - sessionData - .copy( - // position value does not really matter, so just use lastUsageIndex + 1 to ensure that - // the value is always greater than value of any existing account - position = lastUsageIndex + 1, - lastUsageIndex = lastUsageIndex + 1, - ) - .toDbModel() - ) + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val lastUsageIndex = getLastUsageIndex() + database.sessionDataQueries.insertSessionData( + sessionData + .copy( + // position value does not really matter, so just use lastUsageIndex + 1 to ensure that + // the value is always greater than value of any existing account + position = lastUsageIndex + 1, + lastUsageIndex = lastUsageIndex + 1, + ) + .toDbModel() + ) + } } } override suspend fun updateData(sessionData: SessionData) { - sessionDataMutex.withLock { - val result = database.sessionDataQueries.selectByUserId(sessionData.userId) - .executeAsOneOrNull() - ?.toApiModel() + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionData.userId) + .executeAsOneOrNull() + ?.toApiModel() - if (result == null) { - Timber.e("User ${sessionData.userId} not found in session database") - return + if (result == null) { + Timber.e("User ${sessionData.userId} not found in session database") + return@withContext + } + // Copy new data from SDK, but keep application data + database.sessionDataQueries.updateSession( + sessionData.copy( + loginTimestamp = result.loginTimestamp, + position = result.position, + lastUsageIndex = result.lastUsageIndex, + userDisplayName = result.userDisplayName, + userAvatarUrl = result.userAvatarUrl, + userAvatarData = result.userAvatarData, + ).toDbModel() + ) } - // Copy new data from SDK, but keep application data - database.sessionDataQueries.updateSession( - sessionData.copy( - loginTimestamp = result.loginTimestamp, - position = result.position, - lastUsageIndex = result.lastUsageIndex, - userDisplayName = result.userDisplayName, - userAvatarUrl = result.userAvatarUrl, - ).toDbModel() - ) } } - override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { - sessionDataMutex.withLock { + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) { + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return@withContext + } + database.sessionDataQueries.updateSession( + result.copy( + userDisplayName = displayName, + userAvatarUrl = avatarUrl, + userAvatarData = avatarData, + ).toDbModel() + ) + } + } + } + + override suspend fun setLatestSession(sessionId: String) { + withContext(dispatchers.io) { + val latestSession = getLatestSession() + if (latestSession?.userId == sessionId) { + // Already the latest session + return@withContext + } + val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 val result = database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() if (result == null) { Timber.e("User $sessionId not found in session database") - return + return@withContext + } + sessionDataMutex.withLock { + // Update lastUsageIndex of the session + database.sessionDataQueries.updateSession( + result.copy( + lastUsageIndex = lastUsageIndex + 1, + ).toDbModel() + ) } - database.sessionDataQueries.updateSession( - result.copy( - userDisplayName = displayName, - userAvatarUrl = avatarUrl, - ).toDbModel() - ) - } - } - - override suspend fun setLatestSession(sessionId: String) { - val latestSession = getLatestSession() - if (latestSession?.userId == sessionId) { - // Already the latest session - return - } - val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 - val result = database.sessionDataQueries.selectByUserId(sessionId) - .executeAsOneOrNull() - ?.toApiModel() - if (result == null) { - Timber.e("User $sessionId not found in session database") - return - } - sessionDataMutex.withLock { - // Update lastUsageIndex of the session - database.sessionDataQueries.updateSession( - result.copy( - lastUsageIndex = lastUsageIndex + 1, - ).toDbModel() - ) } } @@ -138,32 +149,32 @@ class DatabaseSessionStore( ?: -1L } - override suspend fun getLatestSession(): SessionData? { - return sessionDataMutex.withLock { + override suspend fun getLatestSession(): SessionData? = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectLatest() .executeAsOneOrNull() ?.toApiModel() } } - override suspend fun getSession(sessionId: String): SessionData? { - return sessionDataMutex.withLock { + override suspend fun getSession(sessionId: String): SessionData? = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() } } - override suspend fun getAllSessions(): List { - return sessionDataMutex.withLock { + override suspend fun getAllSessions(): List = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectAll() .executeAsList() .map { it.toApiModel() } } } - override suspend fun numberOfSessions(): Int { - return sessionDataMutex.withLock { + override suspend fun numberOfSessions(): Int = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.count() .executeAsOneOrNull() ?.toInt() @@ -179,8 +190,10 @@ class DatabaseSessionStore( } override suspend fun removeSession(sessionId: String) { - sessionDataMutex.withLock { - database.sessionDataQueries.removeSession(sessionId) + withContext(dispatchers.io) { + sessionDataMutex.withLock { + database.sessionDataQueries.removeSession(sessionId) + } } } } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index 316bf86c1c..0c55d46f66 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -31,6 +31,7 @@ internal fun SessionData.toDbModel(): DbSessionData { lastUsageIndex = lastUsageIndex, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, + userAvatarData = userAvatarData, ) } @@ -52,5 +53,6 @@ internal fun DbSessionData.toApiModel(): SessionData { lastUsageIndex = lastUsageIndex, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, + userAvatarData = userAvatarData, ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/13.db b/libraries/session-storage/impl/src/main/sqldelight/databases/13.db new file mode 100644 index 0000000000..e2d2bebe6a Binary files /dev/null and b/libraries/session-storage/impl/src/main/sqldelight/databases/13.db differ diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index 8e5fb7a1f8..869bca34ed 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -36,7 +36,10 @@ CREATE TABLE SessionData ( -- user display name userDisplayName TEXT, -- user avatar url - userAvatarUrl TEXT + userAvatarUrl TEXT, + -- added in version 13 + -- user avatar thumbnail data, base64 encoded + userAvatarData TEXT ); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm new file mode 100644 index 0000000000..f6ba7fb28a --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm @@ -0,0 +1,4 @@ +-- Migrate DB from version 12 +-- Add the thumbnail of the user avatar, base64 encoded, so that the avatar can be rendered without a Matrix client. + +ALTER TABLE SessionData ADD COLUMN userAvatarData TEXT; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index e40000986b..cc91ee8039 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -134,17 +134,18 @@ class DatabaseSessionStoreTest { @Test fun `updateUserProfile does nothing if the session is not found`() = runTest { - databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl", "userAvatarData") assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull() } @Test fun `updateUserProfile update the data`() = runTest { database.sessionDataQueries.insertSessionData(aSessionData) - databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl") + databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl", "userAvatarData") val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne() assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName") assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl") + assertThat(updatedSession.userAvatarData).isEqualTo("userAvatarData") } @Test @@ -231,6 +232,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 0, userDisplayName = "userDisplayName", userAvatarUrl = "userAvatarUrl", + userAvatarData = "userAvatarData", ) val secondSessionData = SessionData( userId = "userId", @@ -249,6 +251,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 1, userDisplayName = "userDisplayNameAltered", userAvatarUrl = "userAvatarUrlAltered", + userAvatarData = "userAvatarDataAltered", ) assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId) assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp) @@ -273,6 +276,7 @@ class DatabaseSessionStoreTest { assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex) assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName) assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl) + assertThat(alteredSession.userAvatarData).isEqualTo(firstSessionData.userAvatarData) } @Test @@ -294,6 +298,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 0, userDisplayName = "userDisplayName", userAvatarUrl = "userAvatarUrl", + userAvatarData = "userAvatarData", ) val secondSessionData = SessionData( userId = "userIdUnknown", @@ -312,6 +317,7 @@ class DatabaseSessionStoreTest { lastUsageIndex = 1, userDisplayName = "userDisplayNameAltered", userAvatarUrl = "userAvatarUrlAltered", + userAvatarData = "userAvatarDataAltered", ) assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt index 4a488588ff..a6292631e9 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt @@ -30,4 +30,5 @@ internal fun aDbSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) diff --git a/libraries/session-storage/test/build.gradle.kts b/libraries/session-storage/test/build.gradle.kts index 7a89746812..35e40e1fed 100644 --- a/libraries/session-storage/test/build.gradle.kts +++ b/libraries/session-storage/test/build.gradle.kts @@ -15,6 +15,5 @@ android { dependencies { implementation(libs.coroutines.core) - implementation(projects.libraries.matrix.api) implementation(projects.libraries.sessionStorage.api) } diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index 05e58ac623..ac54c1f1d4 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.map class InMemorySessionStore( initialList: List = emptyList(), - private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") }, + private val updateUserProfileResult: (String, String?, String?, String?) -> Unit = { _, _, _, _ -> error("Not implemented") }, private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") }, ) : SessionStore { private val sessionDataListFlow = MutableStateFlow(initialList) @@ -56,8 +56,8 @@ class InMemorySessionStore( } } - override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) { - updateUserProfileResult(sessionId, displayName, avatarUrl) + override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) { + updateUserProfileResult(sessionId, displayName, avatarUrl, avatarData) } override suspend fun getSession(sessionId: String): SessionData? { diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt index c5acd77755..c7c1856f8c 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt @@ -23,6 +23,7 @@ fun aSessionData( lastUsageIndex: Long = 0, userDisplayName: String? = null, userAvatarUrl: String? = null, + userAvatarData: String? = null, ): SessionData { return SessionData( userId = sessionId, @@ -41,5 +42,6 @@ fun aSessionData( lastUsageIndex = lastUsageIndex, userDisplayName = userDisplayName, userAvatarUrl = userAvatarUrl, + userAvatarData = userAvatarData, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 937561e2a5..6d4c7ea710 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -15,12 +15,14 @@ import android.graphics.Typeface import android.text.TextPaint import android.text.TextUtils import android.text.style.ReplacementSpan +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout import androidx.core.text.getSpans import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.wysiwyg.view.spans.CustomMentionSpan -import kotlin.math.roundToInt +import kotlin.math.ceil /** * A span that represents a mention (user, room, etc.) in text. @@ -38,8 +40,12 @@ class MentionSpan( private var endPadding: Int = 0 private var typeface: Typeface = Typeface.DEFAULT - private var measuredTextWidth = 0 - private var measuredTrailingWidth = 0 + /** + * The widest the pill is allowed to be, in pixels. A pill wider than this is truncated, so that + * [getSize] never reports a width the line can't accommodate: the layout would then be unable to + * move the pill to the next line and would leave it overflowing the end of the current one. + */ + private var maxWidth: Int = NO_MAX_WIDTH // The formatted display text, will be set by the formatter var displayText: CharSequence = "" @@ -88,6 +94,50 @@ class MentionSpan( this.trailingText = trailingText } + /** + * Sets the widest this pill may be. Pass [NO_MAX_WIDTH] to leave it unbounded. + */ + fun updateMaxWidth(maxWidthPx: Int) { + maxWidth = maxWidthPx + } + + /** + * Measures the pill with [paint], truncating the display text so the result never exceeds + * [maxWidth]. + * + * Both [getSize] and [draw] go through here: the layout reserves exactly what is painted. They + * can't share a cached measurement instead, because the platform skips [getSize] before [draw] + * for the last span of a line, and the same [MentionSpan] instance is reused across ranges and + * across layouts. + */ + private fun measure(paint: Paint): Measurements { + textPaint.set(paint) + textPaint.typeface = typeface + val trailing = trailingText?.let { " $it" } + val trailingWidth = trailing?.let { textPaint.measureText(it) } ?: 0f + val availableForText = if (maxWidth == NO_MAX_WIDTH) { + Float.MAX_VALUE + } else { + (maxWidth - startPadding - endPadding - trailingWidth).coerceAtLeast(0f) + } + val fullTextWidth = textPaint.measureText(displayText, 0, displayText.length) + return if (fullTextWidth <= availableForText) { + Measurements(displayText, fullTextWidth, trailing, trailingWidth) + } else { + val truncated = TextUtils.ellipsize(displayText, textPaint, availableForText, TextUtils.TruncateAt.END) + Measurements(truncated, textPaint.measureText(truncated, 0, truncated.length), trailing, trailingWidth) + } + } + + private inner class Measurements( + val text: CharSequence, + val textWidth: Float, + val trailing: String?, + val trailingWidth: Float, + ) { + val width: Float get() = textWidth + trailingWidth + startPadding + endPadding + } + override fun getSize( paint: Paint, text: CharSequence?, @@ -95,12 +145,7 @@ class MentionSpan( end: Int, fm: Paint.FontMetricsInt? ): Int { - textPaint.set(paint) - textPaint.typeface = typeface - // Measure the full text width without truncation - measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt() - measuredTrailingWidth = trailingText?.let { textPaint.measureText(" $it").roundToInt() } ?: 0 - return measuredTextWidth + measuredTrailingWidth + startPadding + endPadding + return ceil(measure(paint).width).toInt() } override fun draw( @@ -114,13 +159,9 @@ class MentionSpan( bottom: Int, paint: Paint ) { - val availableWidth = (canvas.width - x).coerceAtLeast(0f) - - val measuredWidth = measuredTextWidth + measuredTrailingWidth + startPadding + endPadding - val pillWidth = minOf(availableWidth, measuredWidth.toFloat()) - + val measurements = measure(paint) backgroundPaint.color = backgroundColor - val rect = RectF(x, top.toFloat(), x + pillWidth, bottom.toFloat()) + val rect = RectF(x, top.toFloat(), x + measurements.width, bottom.toFloat()) val radius = rect.height() / 2 canvas.drawRoundRect(rect, radius, radius, backgroundPaint) @@ -128,18 +169,14 @@ class MentionSpan( textPaint.color = textColor textPaint.typeface = typeface - val availableWidthForText = availableWidth - startPadding - endPadding - measuredTrailingWidth - val textToDraw = if (measuredTextWidth > availableWidthForText) { - TextUtils.ellipsize(displayText, textPaint, availableWidthForText, TextUtils.TruncateAt.END) - } else { - displayText + canvas.drawText(measurements.text, 0, measurements.text.length, x + startPadding, y.toFloat(), textPaint) + measurements.trailing?.let { trailing -> + canvas.drawText(trailing, x + startPadding + measurements.textWidth, y.toFloat(), textPaint) } - canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint) + } - trailingText?.let { - val drawnTextWidth = minOf(availableWidthForText, measuredTextWidth.toFloat()) - canvas.drawText(" $it", x + startPadding + drawnTextWidth, y.toFloat(), textPaint) - } + companion object { + const val NO_MAX_WIDTH = Int.MAX_VALUE } } @@ -170,3 +207,17 @@ fun CharSequence.getMentionSpans(start: Int = 0, end: Int = length): List + val maxWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else MentionSpan.NO_MAX_WIDTH + text.getMentionSpans().forEach { it.updateMaxWidth(maxWidth) } + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } +} diff --git a/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml index cdfaabd496..2498ba36b4 100644 --- a/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml +++ b/libraries/textcomposer/impl/src/main/res/values-ro/translations.xml @@ -2,14 +2,14 @@ "Adăugați un atașament" "Comutați lista cu puncte" - "Închideți opțiunile de formatare" + "Anulați și închideți opțiunile de formatare" "Comutați blocul de cod" "Adăugați o descriere" "Mesaj criptat…" "Mesaj…" "Mesaj necriptat…" "Creați un link" - "Editați link-ul" + "Editați linkul" "%1$s, stare: %2$s" "Aplicați formatul aldin" "Aplicați formatul italic" @@ -17,16 +17,16 @@ "dezactivat" "activat" "Aplicați formatul barat" - "Aplică formatul de subliniere" + "Aplicați formatul de subliniere" "Comutați modul ecran complet" "Indentare" "Aplicați formatul de cod inline" "Setați linkul" "Comutați lista numerotată" "Deschideți opțiunile de compunere" - "Aplicați citatul" + "Comutați citatul" "Ștergeți linkul" - "Dez-identare" + "Reduceți indentarea" "Link" "Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi." "Țineți apăsat pentru a înregistra" 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 d08048241e..206da5a3b0 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 @@ -162,7 +162,9 @@ class MarkdownTextInputTest : RobolectricTest() { val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false) setMarkdownTextInput(state = state) val editor = activity!!.findEditor() - editor.requestFocus() + // The test window is in touch mode, where the editor is not focusable in Robolectric. + // requestFocusFromTouch() leaves the touch mode first, like a hardware keyboard would do. + editor.requestFocusFromTouch() // Focus state is updated assertThat(state.hasFocus).isTrue() } diff --git a/libraries/troubleshoot/api/build.gradle.kts b/libraries/troubleshoot/api/build.gradle.kts index 177b2a5fce..5eda87d84f 100644 --- a/libraries/troubleshoot/api/build.gradle.kts +++ b/libraries/troubleshoot/api/build.gradle.kts @@ -16,6 +16,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) - implementation(libs.androidx.corektx) implementation(libs.coroutines.core) } 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 be10a2845e..3bc6ba64fb 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 @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -53,7 +52,6 @@ import io.element.android.libraries.push.api.history.PushHistoryItem import io.element.android.libraries.troubleshoot.impl.R import io.element.android.libraries.ui.strings.CommonStrings -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PushHistoryView( state: PushHistoryState, diff --git a/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml index c28887c3c5..3576fc8323 100644 --- a/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml +++ b/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml @@ -1,12 +1,12 @@ - "Istoricul notificărilor" + "Istoricul notificărilor push" "Rulați testele" "Rulați din nou testele" - "Unele teste au eșuat. Vă rugăm să verificați detaliile." - "Rulați testele pentru a detecta probleme în configurația dumneavoastră care poat face ca notificările să nu se funcționeze corect." + "Nu s-au putut efectua unele teste. Verificați detaliile." + "Rulați testele pentru a detecta probleme în configurația dvs. care pot face ca notificările să nu se funcționeze corect." "Încercați să remediați" "Toate testele au trecut cu succes." "Depanați notificările" - "Unele teste necesită atenția dumneavoastră. Vă rugăm să verificați detaliile." + "Unele teste necesită atenția dvs. Verificați detaliile." diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 11ded645d7..97bbef824d 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -1,5 +1,6 @@ + "Přidat avatar" "Přidat reakci: %1$s" "Adresa" "Profilový obrázek" @@ -10,6 +11,7 @@ "zadány %1$d číslice" "zadáno %1$d číslic" + "Doba trvání: %1$s" "Upravit avatar" "Úplná adresa bude %1$s" "Podrobnosti o šifrování" @@ -18,6 +20,7 @@ "Informace" "Připojit se k hovoru" "Přejít dolů" + "Přejít na nepřečtené" "Přesunout mapu na mou polohu" "Pouze zmínky" "Ztišeno" @@ -34,16 +37,16 @@ "Rychlost přehrávání" "Hlasování" "Hlasování ukončeno" + "Pozice: %1$s" "QR kód" "Reagovat s %1$s" "Reagovat s dalšími emoji" - "%1$s a %2$s přečetli" + "Předchozí zprávu přečetli %1$s a %2$s" - "Přečetl(a) %1$s a %2$d další" - "Přečetl(a) %1$s a %2$d další" - "Přečetl(a) %1$s a %2$d dalších" + "Předchozí zprávu přečetl(a) %1$s a %2$d další" + "Předchozí zprávu přečetl(a) %1$s a %2$d další" - "%1$s přečetl(a)" + "Předchozí zprávu přečetl(a) %1$s" "Odstraňit reakci s %1$s" "Odstranit reakci pomocí %1$s" "Avatar místnosti" @@ -97,6 +100,7 @@ "Odmítnout a zablokovat" "Smazat" "Smazat účet" + "Smazat soubor" "Odstranit hlasování" "Odznačit vše" "Zakázat" @@ -304,6 +308,7 @@ Důvod: %1$s." "Počkejte prosím…" "Opravdu chcete ukončit toto hlasování?" "Hlasování: %1$s" + "Hlasování" "Celkový počet hlasů: %1$s" "Výsledky se zobrazí po skončení hlasování" @@ -453,7 +458,7 @@ Opravdu chcete pokračovat?" "Vyberte kvalitu videa, které chcete nahrát." "Vyberte kvalitu nahrávání videa" "Na tomto zařízení jste již přihlášeni jako %1$s." - "Váš domovský server je třeba upgradovat, aby podporoval službu Matrix Authentication Service a vytváření účtu." + "Váš poskytovatel účtu musí být upgradeován, aby podporoval Matrix Authentication Service a vytváření účtů." "Vytvoření trvalého odkazu se nezdařilo" "%1$s nemohl načíst mapu. Zkuste to prosím později." "Načítání zpráv se nezdařilo" @@ -547,4 +552,6 @@ Opravdu chcete pokračovat?" "Nemáte přístup k této zprávě" "Nelze dešifrovat zprávu" "Tato zpráva byla zablokována buď proto, že jste neověřili své zařízení, nebo proto, že odesílatel musí ověřit vaši digitální identitu." + "Vaše zařízení je příliš staré, je vyžadováno zařízení s Androidem 8 nebo novějším." + "Vykreslování map není podporováno" 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 ccd1cffeda..cefeb75720 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -186,7 +186,6 @@ "Entschlüsselung wiederholen" "Speichern" "Suchen" - "Auswählen" "Alles auswählen" "Senden" "Bearbeitete Nachricht senden" diff --git a/libraries/ui-strings/src/main/res/values-el/translations.xml b/libraries/ui-strings/src/main/res/values-el/translations.xml index 62306c1985..97223c8d3f 100644 --- a/libraries/ui-strings/src/main/res/values-el/translations.xml +++ b/libraries/ui-strings/src/main/res/values-el/translations.xml @@ -1,14 +1,20 @@ + "Προσθήκη avatar" "Προσθήκη αντίδρασης: %1$s" "Διεύθυνση" "Εικόνα Προφίλ" + "Εκκαθάριση πεδίου αναζήτησης" "Ελαχιστοποίηση πεδίου κειμένου μηνύματος" + "Μείωση επιτρεπόμενων ψήφων ανά άτομο" + "Αύξηση επιτρεπόμενων ψήφων ανά άτομο" + "Προσαρμοσμένο emoji" "Διαγραφή" "%1$d ψηφίο εισήχθη" "%1$d ψηφία εισήχθησαν" + "Διάρκεια: %1$s" "Επεξεργασία Άβαταρ" "Η πλήρης διεύθυνση θα είναι %1$s" "Λεπτομέρειες κρυπτογράφησης" @@ -17,6 +23,8 @@ "Πληροφορίες" "Συμμετοχή στην κλήση" "Άλμα προς τα κάτω" + "Μετάβαση σε μη αναγνωσμένα" + "Μετάβαση στο πρώτο μη αναγνωσμένο μήνυμα" "Μετακινήστε τον χάρτη στην τοποθεσία μου" "Αναφορές μόνο" "Σε σίγαση" @@ -27,12 +35,14 @@ "Σελίδα %1$d" "Παύση" "Φωνητικό μήνυμα, διάρκεια:%1$s, τρέχουσα θέση: %2$s" + "Προεπισκόπηση φωτογραφίας" "Πεδίο PIN" "Καρφιτσωμένη τοποθεσία" "Αναπαραγωγή" "Ταχύτητα αναπαραγωγής" "Δημοσκόπηση" "Ολοκληρωμένη δημοσκόπηση" + "Θέση: %1$s" "Κωδικός QR" "Αντέδρασε με %1$s" "Αντέδρασε με άλλα emoji" @@ -42,20 +52,26 @@ "Διαβάστηκε από %1$s και %2$d ακόμη" "Το προηγούμενο μήνυμα διαβάστηκε από %1$s" + "εμφάνιση όλων των αποδεικτικών ανάγνωσης" "Αφαίρεση αντίδρασης με %1$s" "Αφαιρέστε την αντίδραση με %1$s" "Άβαταρ αίθουσας" "Αποστολή αρχείων" "Τοποθεσία αποστολέα" + "Απεστάλη από %1$s στις %2$s" "Απαιτείται ενέργεια περιορισμένης χρονικής διάρκειας, έχετε ένα λεπτό για επαλήθευση." "Ρυθμίσεις, απαιτείται ενέργεια" "Εμφάνιση κωδικού πρόσβασης" "Ξεκίνησε μια κλήση" "Ξεκινήστε μια βιντεοκλήση" "Έναρξη φωνητικής κλήσης" + "Κατάσταση" + "Νήμα στο %1$s" + "Νήματα στο %1$s" "Θαμένη αίθουσα" "Άβαταρ χρήστη" "Μενού χρήστη" + "Προεπισκόπηση βίντεο" "Προβολή avatar" "Προβολή λεπτομερειών" "Φωνητικό μήνυμα, διάρκεια: %1$s" @@ -92,6 +108,7 @@ "Απόρριψη και αποκλεισμός" "Διαγραφή" "Διαγραφή λογαριασμού" + "Διαγραφή αρχείου" "Διαγραφή Δημοσκόπησης" "Αποεπιλογή όλων" "Απενεργοποίηση" @@ -129,6 +146,7 @@ "Διαχείριση λογαριασμού και συσκευών" "Διαχείριση συσκευών" "Διαχείριση αίθουσών" + "Σήμανση ως αναγνωσμένου" "Στείλε" "Ελαχιστοποίηση" "Επόμενο" @@ -183,9 +201,11 @@ "Μετάφραση" "Προσπάθησε ξανά" "Ξεκαρφίτσωμα" + "Βιντεοκλήση" "Προβολή" "Προβολή στο χρονοδιάγραμμα" "Προβολή πηγής" + "Φωνητική κλήση" "Ναι" "Ναι, δοκιμή ξανά" "Ο διακομιστής σου υποστηρίζει τώρα ένα νέο, ταχύτερο πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για αναβάθμιση τώρα. Κάνοντας αυτό τώρα θα σε βοηθήσει να αποφύγεις μια αναγκαστική αποσύνδεση όταν το παλιό πρωτόκολλο καταργηθεί αργότερα." @@ -206,6 +226,7 @@ "Αποκλεισμένοι χρήστες" "Φυσαλίδες" "Η κλήση απορρίφθηκε" + "Κλήση σε εξέλιξη" "Η κλήση ξεκίνησε" "Απορρίψατε μια κλήση" "Αντίγραφο ασφαλείας συνομιλίας" @@ -249,7 +270,17 @@ "Αρχείο αποθηκευμένο σε Λήψεις" "Προώθηση μηνύματος" "Χρησιμοποιείται συχνά" + "Γκαλερί" + + "%1$d συνημμένο…" + "%1$d συνημμένα…" + + + "%1$d στοιχείο πολυμέσων…" + "%1$d στοιχεία πολυμέσων…" + "GIF" + "Ομαδική κλήση σε εξέλιξη" "Εικόνα" "Σε απάντηση στον χρήστη %1$s" "Εγκατάσταση APK" @@ -285,6 +316,7 @@ "Δεν υπάρχει όνομα χώρου" "Χωρίς κρυπτογράφηση" "Εκτός σύνδεσης" + "Σε κλήση" "Άδειες ανοιχτού κώδικα" "ή" "Άλλες επιλογές" @@ -297,6 +329,7 @@ "Παρακαλώ περίμενε…" "Θες σίγουρα να τερματίσεις αυτή τη δημοσκόπηση;" "Δημοσκόπηση: %1$s" + "Δημοσκόπηση" "Σύνολο ψήφων: %1$s" "Τα αποτελέσματα θα εμφανιστούν μετά τη λήξη της ψηφοφορίας" @@ -391,6 +424,7 @@ "Άρση σίγασης" "Μη υποστηριζόμενη κλήση" "Μη υποστηριζόμενο συμβάν" + "Ο χρήστης %1$s ξεκίνησε μια κλήση" "Όνομα χρήστη" "Η επαλήθευση ακυρώθηκε" "Η επαλήθευση ολοκληρώθηκε" @@ -412,6 +446,11 @@ "Αναμονή για ζωντανή τοποθεσία…" "Οποιοσδήποτε μπορεί να δει το ιστορικό" "Εσύ" + "Η λήψη δεν είναι διαθέσιμη." + "Κάτι πήγε στραβά" + "Σάρωση…" + "Η προεπισκόπηση και η λήψη έχουν απενεργοποιηθεί." + "Το αρχείο δεν είναι ασφαλές" "%1$s (%2$s) μοιράστηκε αυτό το μήνυμα, καθώς δεν ήσασταν στην αίθουσα όταν στάλθηκε." "%1$s μοιράστηκε αυτό το μήνυμα, ενόσω δεν ήσασταν στην αίθουσα όταν στάλθηκε." "Αυτή η αίθουσα έχει διαμορφωθεί έτσι ώστε τα νέα μέλη να μπορούν να διαβάσουν το ιστορικό. %1$s" @@ -462,6 +501,9 @@ "Λυπούμαστε, παρουσιάστηκε σφάλμα" "🔐️ Έλα μαζί μου στο %1$s" "Γεια, μίλα μου στην εφαρμογή %1$s :%2$s" + "Κοινή χρήση τρέχουσας τοποθεσίας" + "Κοινοποίηση τοποθεσίας σε εξέλιξη" + "%1$s Ζωντανή τοποθεσία" "%1$s Android" "Κούνησε δυνατά τη συσκευή σου για να αναφέρεις κάποιο σφάλμα" "Στιγμιότυπο οθόνης" @@ -469,6 +511,7 @@ "Επιλογές" "Αφαίρεση %1$s" "Ρυθμίσεις" + "Επιτρεπόμενες ψήφοι ανά άτομο" "Κανείς δεν μοιράζεται την τοποθεσία του" "Κοινοποιείς την τοποθεσία σε πραγματικό χρόνο" @@ -476,6 +519,8 @@ "%1$d άτομα" "Στον χάρτη" + "Ο αρχικός διακομιστής βρίσκεται στο τοπικό σας δίκτυο. Για να συνδεθεί, το %1$s χρειάζεται άδεια πρόσβασης σε συσκευές σε αυτό το δίκτυο." + "Επιτρέψτε την πρόσβαση στο τοπικό δίκτυο" "Αποτυχία επιλογής πολυμέσου, δοκίμασε ξανά." "Πάτα σε ένα μήνυμα και επέλεξε «%1$s» για να συμπεριληφθεί εδώ." "Καρφίτσωσε σημαντικά μηνύματα, ώστε να μπορούν να εντοπιστούν εύκολα" @@ -521,6 +566,10 @@ "Χώροι" "Κοινοποιήθηκε %1$s" "Στον χάρτη" + + "%1$d συμμετέχον" + "%1$d συμμετέχοντες" + "Το μήνυμα δεν αποστάλη επειδή η επαληθευμένη ψηφιακή ταυτότητα του %1$s επαναφέρθηκε." "Το μήνυμα δεν στάλθηκε επειδή ο χρήστης %1$s δεν έχει επαληθεύσει όλες τις συσκευές." "Το μήνυμα δεν στάλθηκε επειδή δεν έχεις επαληθεύσει τουλάχιστον μία από τις συσκευές σου." @@ -532,4 +581,6 @@ "Δεν έχεις πρόσβαση σε αυτό το μήνυμα" "Δεν είναι δυνατή η αποκρυπτογράφηση μηνύματος" "Αυτό το μήνυμα αποκλείστηκε είτε επειδή δεν επαληθεύσατε τη συσκευή σας είτε επειδή ο αποστολέας πρέπει να επαληθεύσει την ψηφιακή σας ταυτότητα." + "Η συσκευή σας είναι πολύ παλιά, απαιτείται συσκευή με Android 8 ή νεότερη έκδοση." + "Η απόδοση χαρτών δεν υποστηρίζεται" diff --git a/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml b/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml index 2fc2fb0889..dde372feeb 100644 --- a/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml +++ b/libraries/ui-strings/src/main/res/values-en-rUS/translations.xml @@ -5,4 +5,5 @@ "Favorite" "Favorited" "This room has been configured so that new members can read history. %1$s" + "Ignore battery optimization" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index 288a771296..027c060007 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -186,7 +186,6 @@ "Yritä salauksen purkamista uudelleen" "Tallenna" "Hae" - "Valitse" "Valitse kaikki" "Lähetä" "Lähetä muokattu viesti" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 179398d61c..e804558b1a 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -98,6 +98,7 @@ "Choisir un fichier" "Choisir une photo" "Effacer" + "Tout effacer" "Fermer" "Terminer la vérification" "Confirmer" @@ -108,6 +109,7 @@ "Copier le lien" "Copier le lien vers le message" "Copier le texte" + "Copier dans le presse-papiers" "Créer" "Créer un salon" "Créer un espace" @@ -135,6 +137,10 @@ "Terminer" "Mot de passe oublié ?" "Transférer" + + "Transférer %1$d message" + "Transférer %1$d messages" + "Retour" "Accédez à l’écran « Rôles et autorisations »." "Ouvrir les paramètres" @@ -221,6 +227,7 @@ "Mise à niveau disponible" "À propos" "Politique d’utilisation acceptable" + "Paramètres du compte" "Ajouter un compte" "Ajouter un autre compte" "Ajout d’une légende" @@ -229,6 +236,7 @@ "Statistiques d’utilisation" "Vous avez quitté le salon" "Vous avez été déconnecté de la session" + "Paramètres de l’application" "Apparence" "Audio" "Bêta" @@ -239,6 +247,8 @@ "Appel démarré" "Vous avez rejeté un appel" "Sauvegarde des discussions" + "Conversations" + "Bloc de code" "Copié dans le presse-papiers" "Droits d’auteur" "Création du salon…" @@ -278,6 +288,7 @@ Raison : %1$s." "Fichier enregistré" "Fichier enregistré dans Téléchargements" "Transférer le message" + "Transférer à" "Fréquemment utilisé" "Galerie" @@ -290,6 +301,7 @@ Raison : %1$s." "GIF" "Appel de groupe en cours" + "Salons" "Image" "En réponse à %1$s" "Installer l’APK" @@ -303,10 +315,12 @@ Raison : %1$s." "Fin de localisation en direct" "Chargement…" "Chargement…" + "Partage de position" "%d autre" "%d autres" + "Qualité d’envoi des médias" "%1$d Membre" "%1$d Membres" @@ -316,6 +330,7 @@ Raison : %1$s." "Échec de l’envoi du message" "Mode d’affichage des messages" "Message supprimé" + "Modération & sécurité" "Moderne" "Mettre en sourdine" "Nom" @@ -401,6 +416,7 @@ Raison : %1$s." "Position en direct partagée" "Position partagée" "Espace partagé" + "Connecté" "Suppression de l’appareil" "Une erreur s’est produite" "Nous avons rencontré un problème. Veuillez réessayer." @@ -416,6 +432,7 @@ Raison : %1$s." "Succès" "Recommandé" "Suggestions" + "Changer de compte" "Synchronisation" "Texte" "Avis de tiers" @@ -455,6 +472,7 @@ Raison : %1$s." "En attente de la position en temps réel…" "Tout le monde peut voir les anciens messages" "Vous" + "Votre compte" "Téléchargement indisponible." "Une erreur est survenue" "Analyse en cours…" @@ -555,11 +573,20 @@ Raison : %1$s." "Développer" "Réduire" "Partage de la position en direct" + "Nombre maximal de messages sélectionnés" + + "%1$d message transféré" + "%1$d messages transférés" + "Vous êtes déjà dans ce salon!" "%1$s sur %2$s" "%1$s Messages épinglés" "Chargement du message…" "Voir tout" + + "%1$d message sélectionné" + "%1$d messages sélectionnés" + "Discussion" "Partage de position" "Partager ma position" 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 0edd88e18a..6ca03a9870 100644 --- a/libraries/ui-strings/src/main/res/values-hr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml @@ -99,6 +99,7 @@ "Odaberite datoteku" "Odaberi fotografiju" "Očisti" + "Očisti sve" "Zatvori" "Dovrši provjeru" "Potvrdi" @@ -109,6 +110,7 @@ "Kopiraj poveznicu" "Kopiraj poveznicu u poruku" "Kopiraj tekst" + "Kopiraj u međuspremnik" "Stvori" "Napravi sobu" "Stvori prostor" @@ -136,6 +138,10 @@ "Završi" "Zaboravili ste zaporku?" "Proslijedi" + + "Proslijedi poruku %1$d" + "Prosljeđivanje poruka %1$d" + "Idi natrag" "Idi na uloge i dopuštenja" "Idi na postavke" @@ -187,7 +193,6 @@ "Ponovno pokušaj dešifrirati" "Spremi" "Pretraži" - "Odaberite" "Odaberi sve" "Pošalji" "Pošalji uređenu poruku" @@ -223,6 +228,7 @@ "Dostupna je nadogradnja" "O aplikaciji" "Pravilnik o prihvatljivoj upotrebi" + "Postavke računa" "Dodaj račun" "Dodaj još jedan račun" "Dodavanje opisa" @@ -231,6 +237,7 @@ "Analitika" "Napustili ste sobu" "Odjavljeni ste iz sesije" + "Postavke aplikacije" "Izgled" "Audiozapis" "Beta" @@ -241,6 +248,8 @@ "Poziv je započeo" "Odbili ste poziv" "Sigurnosna kopija razgovora" + "Razgovori" + "Blok koda" "Kopirano u međuspremnik" "Autorsko pravo" "Stvaranje sobe…" @@ -280,6 +289,7 @@ Razlog: %1$s ." "Datoteka je spremljena" "Datoteka je spremljena u preuzimanja" "Proslijedi poruku" + "Proslijedi na" "Često korišteno" "Galerija" @@ -292,6 +302,7 @@ Razlog: %1$s ." "GIF" "Grupni poziv u tijeku" + "Sobe" "Slika" "Kao odgovor osobi %1$s" "Instaliraj APK" @@ -305,11 +316,13 @@ Razlog: %1$s ." "Prikaz lokacije uživo je završio" "Učitavanje…" "Učitava se još…" + "Dijeljenje lokacije" "još %d" "još %d" "još %d" + "Kvaliteta prijenosa medija" "%1$d član" "%1$d člana" @@ -320,11 +333,13 @@ Razlog: %1$s ." "Slanje poruke nije uspjelo" "Izgled poruke" "Poruka je uklonjena" + "Umjerenost i sigurnost" "Suvremeni" "Isključi zvuk" "Naziv" "%1$s (%2$s)" "Nema rezultata" + "Nema rezultata za „%1$s”. Pokušajte s novim pojmom za pretraživanje." "Nema naziva sobe" "Nema naziva prostora" "Nije šifrirano" @@ -372,6 +387,7 @@ Razlog: %1$s ." "Odgovara korisniku %1$s" "Prijavi problem" "Prijava je podnesena" + "Rezultati za „%1$s”" "Uređivač obogaćenog teksta" "Uloga" "Soba" @@ -408,6 +424,7 @@ Razlog: %1$s ." "Dijeljena lokacija uživo" "Podijeljena lokacija" "Zajednički prostor" + "Prijavljen/a" "Uklanjanje uređaja" "Nešto je pošlo po zlu" "Naišli smo na problem. Pokušajte ponovno." @@ -424,6 +441,7 @@ Razlog: %1$s ." "Uspjeh" "Preporučeno" "Prijedlozi" + "Promijeni račun" "Sinkronizacija" "Tekst" "Obavijesti trećih strana" @@ -463,6 +481,7 @@ Razlog: %1$s ." "Čekanje lokacije uživo…" "Svatko može vidjeti povijest" "Vi" + "Vaš račun" "Preuzimanje nije dostupno." "Datoteka nije pronađena" "Skeniranje…" @@ -548,6 +567,14 @@ Jeste li sigurni da želite nastaviti?" "%1$d prikvačenih poruka" "Prikvačene poruke" + "Zanemari optimizaciju baterije" + "Održavaj aktivnost u pozadini" + "Prikriveni način rada" + "Utišaj sve kanale" + "Prikaži na zaključanom zaslonu" + "Pristup sa zaključanog zaslona" + "Omogući preklapanje sustava" + "Pristup plutajućem gumbu za radnju" "Spremate se otići u svoj %1$s račun kako biste poništili identitet. Nakon toga bit ćete vraćeni u aplikaciju." "Ne možete potvrditi? Idite na svoj račun kako biste poništili svoj identitet." "Povuci potvrdu i pošalji" @@ -566,6 +593,10 @@ Jeste li sigurni da želite nastaviti?" "Smanji" "Dijeljenje lokacije uživo" "Odabran je maksimalan broj poruka" + + "%1$d poruka proslijeđena" + "Proslijeđene poruke %1$d" + "Već gledam ovu sobu!" "%1$s od %2$s" "%1$s Prikvačene poruke" 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 3749f76f16..cad530847b 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -98,6 +98,7 @@ "Fájl kiválasztása" "Fénykép kiválasztása" "Törlés" + "Összes törlése" "Bezárás" "Ellenőrzés befejezése" "Megerősítés" @@ -108,6 +109,7 @@ "Hivatkozás másolása" "Üzenethivatkozás másolása" "Szöveg másolása" + "Másolás a vágólapra" "Létrehozás" "Szoba létrehozása" "Tér létrehozása" @@ -135,6 +137,10 @@ "Befejezés" "Elfelejtette a jelszót?" "Tovább" + + "%1$d üzenet továbbítása" + "%1$d üzenet továbbítása" + "Visszalépés" "Ugrás a szerepkörökre és jogosultságokra" "Ugrás a beállításokhoz" @@ -186,7 +192,6 @@ "Visszafejtés újbóli megpróbálása" "Mentés" "Keresés" - "Kiválasztás" "Összes kijelölése" "Küldés" "Szerkesztett üzenet küldése" @@ -222,6 +227,7 @@ "Frissítés érhető el" "Névjegy" "Elfogadható használatra vonatkozó szabályzat" + "Fiókbeállítások" "Fiók hozzáadása" "Másik fiók hozzáadása" "Felirat hozzáadása" @@ -230,6 +236,7 @@ "Elemzések" "Elhagyta a szobát" "Ki lett jelentkeztetve a munkamenetből" + "Alkalmazásbeállítások" "Megjelenítés" "Hang" "Béta" @@ -240,6 +247,8 @@ "A hívás elindult" "Elutasított egy hívást" "Csevegés biztonsági mentése" + "Csevegések" + "Kódblokk" "A vágólapra másolva" "Szerzői jogok" "Szoba létrehozása…" @@ -279,6 +288,7 @@ Ok: %1$s." "A fájl mentve" "A fájl a Letöltések mappába mentve" "Üzenet továbbítása" + "Továbbítás:" "Gyakran használt" "Galéria" @@ -291,6 +301,7 @@ Ok: %1$s." "GIF" "Csoportos hívás folyamatban" + "Szobák" "Kép" "Válasz erre: %1$s" "APK telepítése" @@ -304,10 +315,12 @@ Ok: %1$s." "Élő pozíciómegosztás befejezve" "Betöltés…" "Továbbiak betöltése…" + "Tartózkodási hely megosztása" "%d további felhasználó" "%d további felhasználó" + "Médiafeltöltés minősége" "%1$d tag" "%1$d tag" @@ -317,11 +330,13 @@ Ok: %1$s." "Az üzenet elküldése sikertelen" "Üzenet elrendezése" "Üzenet eltávolítva" + "Moderálás és biztonság" "Modern" "Némítás" "Név" "%1$s (%2$s)" "Nincs találat" + "Nincsenek találatok erre a kifejezésre: „%1$s” . Próbáljon ki egy új keresési kifejezést." "Nincs szobanév" "Nincs térnév" "Nincs titkosítva" @@ -366,6 +381,7 @@ Ok: %1$s." "Válasz %1$s számára" "Probléma jelentése" "A jelentés elküldve" + "Eredmények a következőre: „%1$s”" "Formázott szöveges szerkesztő" "Szerepkör" "Szoba" @@ -401,6 +417,7 @@ Ok: %1$s." "Megosztott élő helymeghatározás" "Megosztott tartózkodási hely" "Megosztott tér" + "Bejelentkezve" "Eszköz eltávolítása" "Valamilyen hiba történt" "Problémába ütköztünk. Próbálja újra." @@ -456,6 +473,7 @@ Ok: %1$s." "Várakozás az élő helymeghatározásra…" "Bárki láthatja az előzményeket" "Ön" + "Saját fiók" "A letöltés nem érhető el." "A fájl nem található" "Keresés…" @@ -539,6 +557,14 @@ Biztos, hogy folytatja?" "%1$d kitűzött üzenet" "Kitűzött üzenetek" + "Az akkumulátoroptimalizálás figyelmen kívül hagyása" + "Háttérben is aktív marad" + "Rejtett mód" + "Az összes csatorna némítása" + "Megjelenítés a zárolási képernyőn" + "Hozzáférés a zárolási képernyőről" + "Rendszer-overlay engedélyezése" + "Lebegő műveletgomb elérése" "Arra készül, hogy belépjen a(z) %1$s fiókjába, hogy alaphelyzetbe állítsa a digitális személyazonosságát. Ezután vissza fog térni az alkalmazásba." "Nem tudja megerősíteni? Ugorjon a fiókjához, és állítsa alaphelyzetbe a digitális személyazonosságát." "Ellenőrzés visszavonása és elküldés" @@ -557,6 +583,10 @@ Biztos, hogy folytatja?" "Csökkentés" "Élő helymegosztás" "Maximális üzenet kiválasztva" + + "%1$d üzenet továbbítva" + "%1$d üzenet továbbítva" + "Már ezt a szobát nézi!" "%1$s. / %2$s" "%1$s kitűzött üzenet" diff --git a/libraries/ui-strings/src/main/res/values-ja/translations.xml b/libraries/ui-strings/src/main/res/values-ja/translations.xml index 52b5e6bd29..c99e866d7b 100644 --- a/libraries/ui-strings/src/main/res/values-ja/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ja/translations.xml @@ -183,7 +183,6 @@ "復号化を再試行" "保存" "検索" - "選択" "すべて選択" "送信" "編集したメッセージを送信" 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 3865706e00..f17323b5ab 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -101,6 +101,7 @@ "Wybierz plik" "Wybierz zdjęcie" "Wyczyść" + "Wyczyść wszystko" "Zamknij" "Dokończ weryfikację" "Potwierdź" @@ -111,6 +112,7 @@ "Kopiuj link" "Kopiuj link do wiadomości" "Kopiuj tekst" + "Kopiuj do schowka" "Utwórz" "Utwórz pokój" "Utwórz przestrzeń" @@ -138,6 +140,11 @@ "Zakończ" "Nie pamiętasz hasła?" "Przekaż dalej" + + "Przekaż %1$d wiadomość" + "Przekaż %1$d wiadomości" + "Przekaż %1$d wiadomości" + "Wróć" "Przejdź do ról i uprawnień" "Przejdź do ustawień" @@ -189,7 +196,6 @@ "Ponów próbę odszyfrowania" "Zapisz" "Szukaj" - "Zaznacz" "Zaznacz wszystko" "Wyślij" "Wyślij edytowaną wiadomość" @@ -225,6 +231,7 @@ "Dostępna aktualizacja" "O programie" "Polityka użytkowania" + "Ustawienia konta" "Dodaj konto" "Dodaj kolejne konto" "Dodawanie opisu" @@ -233,7 +240,8 @@ "Dane analityczne" "Opuściłeś pokój" "Zostałeś wylogowany z sesji" - "Wygląd" + "Ustawienia aplikacji" + "Motyw" "Dźwięk" "Beta" "Zablokowani użytkownicy" @@ -243,6 +251,8 @@ "Rozpoczęto rozmowę" "Odrzuciłeś połączenie" "Backup czatu" + "Czaty" + "Blok kodu" "Skopiowano do schowka" "Prawa autorskie" "Tworzenie pokoju…" @@ -282,6 +292,7 @@ Powód: %1$s." "Plik zapisany" "Plik zapisany do folderu Pobrane" "Przekaż wiadomość" + "Przekaż do" "Często używane" "Galeria" @@ -296,6 +307,7 @@ Powód: %1$s." "GIF" "Trwa połączenie grupowe" + "Pokoje" "Zdjęcie" "W odpowiedzi do %1$s" "Zainstaluj APK" @@ -309,11 +321,13 @@ Powód: %1$s." "Zakończono udostępnianie lokalizacji na żywo" "Ładowanie…" "Ładuję więcej…" + "Udostępnianie lokalizacji" "%d inny" "%d innych" "%d innych" + "Jakość przesyłanych mediów" "%1$d członek" "%1$d członków" @@ -324,11 +338,13 @@ Powód: %1$s." "Nie udało się wysłać wiadomości" "Układ wiadomości" "Wiadomość usunięta" + "Moderacja i bezpieczeństwo" "Nowoczesny" "Wycisz" "Nazwa" "%1$s (%2$s)" "Brak wyników" + "Nie znaleziono wyników dla “%1$s”. Prosimy o wpisanie nowego terminu." "Brak nazwy pokoju" "Brak nazwy przestrzeni" "Nieszyfrowane" @@ -376,6 +392,7 @@ Powód: %1$s." "Odpowiadanie do %1$s" "Zgłoś problem" "Zgłoszenie wysłane" + "Wyniki dla “%1$s”" "Bogaty edytor tekstu" "Rola" "Pokój" @@ -413,6 +430,7 @@ Powód: %1$s." "Udostępniona lokalizacja na żywo" "Udostępniona lokalizacja" "Udostępniona przestrzeń" + "Zalogowano" "Usuwanie urządzenia" "Coś poszło nie tak" "Napotkaliśmy problem. Spróbuj ponownie." @@ -429,6 +447,7 @@ Powód: %1$s." "Sukces" "Polecane" "Sugestie" + "Zmień konto" "Synchronizuję" "Tekst" "Informacje stron trzecich" @@ -468,6 +487,7 @@ Powód: %1$s." "Czekam na lokalizację na żywo…" "Każdy może zobaczyć historię" "Ty" + "Twoje konto" "Pobieranie niedostępne." "Nie znaleziono pliku" "Skanuję…" @@ -553,6 +573,14 @@ Czy na pewno chcesz kontynuować?" "%1$d przypiętych wiadomości" "Przypięte wiadomości" + "Ignoruj ​​optymalizację baterii" + "Pozostań aktywny w tle" + "Tryb ukryty" + "Wycisz wszystkie kanały" + "Pokaż na ekranie blokady" + "Dostęp z ekranu blokady" + "Włącz nakładkę systemową" + "Uzyskaj dostęp do pływającego przycisku akcji" "Zostaniesz przeniesiony na swoje konto %1$s, aby zresetować tożsamość. Wrócisz do aplikacji po zakończeniu." "Nie możesz potwierdzić? Przejdź do swojego konta i zresetuj swoją tożsamość." "Wycofaj weryfikację i wyślij" @@ -571,6 +599,11 @@ Czy na pewno chcesz kontynuować?" "Zmniejsz" "Udostępnianie lokalizacji na żywo" "Zaznaczono maksymalną liczbę wiadomości" + + "Przekazano %1$d wiadomość" + "Przekazano %1$d wiadomości" + "Przekazano %1$d wiadomości" + "Już oglądasz ten pokój!" "%1$s z %2$s" "%1$s przypiętych wiadomości" 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 cd18373e6a..9c70ef0f6f 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -3,17 +3,17 @@ "Adăugați un avatar" "Adăugați o reacție: %1$s" "Adresă" - "Imagine de profil" + "Avatar" "Ș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" + "Ștergeți" "%1$d cifră introdusă" "%1$d cifre introduse" - "%1$d cifre introduse" + "%1$d de cifre introduse" "Durată: %1$s" "Editați avatarul" @@ -21,12 +21,12 @@ "Detalii privind criptarea" "Extindeți câmpul mesajului" "Galerie, atașament %1$d" - "Galerie, scanare eșuată %1$d" + "Galerie, nu s-a putut scana %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, încă %1$d de fișiere media" "Galerie, scanare %1$d" "Galerie, videoclip %1$d" @@ -34,18 +34,18 @@ "Informații" "Alăturați-vă apelului" "Mergeți în jos" - "Săriți la necitite" - "Săriți la primul mesaj necitit" + "Mergeți la necitite" + "Mergeți la primul mesaj necitit" "Mutați harta la locația mea" "Doar mențiuni" "Notificări dezactivate" "Mențiuni noi" "Mesaje noi" - "Apel în desfășurare" - "Avatarul celuilalt utilizator" + "Apel în curs" + "Avatarul altui utilizator" "Pagina %1$d" "Pauză" - "Mesaj vocal, durată:%1$s, poziție curentă: %2$s" + "Mesaj vocal, durată:%1$s, poziție actuală: %2$s" "Previzualizare foto" "Câmp PIN" "Locație fixată" @@ -56,17 +56,17 @@ "Poziție: %1$s" "Cod QR" "Reacționați cu %1$s" - "Reacționați cu alte emoji-uri" + "Reacționați cu alte emojiuri" "Citit de %1$s și %2$s" - "Citit de %1$s și incă %2$d" - "Citit de %1$s și incă %2$d" - "Citit de %1$s și incă %2$d" + "Citit de %1$s și încă %2$d" + "Citit de %1$s și încă %2$d" + "Citit de %1$s și încă %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" + "Îndepărtați reacția: %1$s" "Avatarul camerei" "Trimiteți fișiere" "Locația expeditorului" @@ -77,23 +77,23 @@ "Începeți un apel" "Inițiați un apel video" "Inițiați un apel vocal" - "Statuts" - "Fir în: %1$s" + "Status" + "Fir în %1$s" "Fire în %1$s" - "Cameră terminată" + "Cameră arhivată" "Avatar utilizator" "Meniu utilizator" "Previzualizare video" "Vizualizați avatarul" "Vizualizați detalii" "Mesaj vocal, durată: %1$s" - "Înregistrați un mesaj vocal" + "Înregistrați un mesaj vocal." "Opriți înregistrarea" - "Avatarul dumneavoastră" + "Avatarul dvs." "Acceptați" "Adăugați o descriere" "Adăugați camere existente" - "Adăugați listei de mesaje" + "Adăugați în cronologie" "Înapoi" "Apelați" "Anulați" @@ -101,6 +101,7 @@ "Alegeți fișierul" "Alegeți o fotografie" "Ștergeți" + "Ștergeți tot" "Închideți" "Finalizați verificarea" "Confirmați" @@ -111,23 +112,24 @@ "Copiați linkul" "Copiați linkul către mesaj" "Copiați textul" + "Copiați în clipboard" "Creați" "Creați o cameră" - "Creeați spațiu" + "Creați un spațiu" "Dezactivați" "Dezactivați contul" "Refuzați" "Refuzați și blocați" - "Ștergere" + "Ștergeți" "Ștergeți contul" "Ștergeți fișierul" "Ștergeți sondajul" "Deselectați tot" "Dezactivați" - "Renunţare" - "Renunțați" + "Renunțați" + "Închideți" "Terminat" - "Descărcare" + "Descărcați" "Editați" "Editați descrierea" "Editați sondajul" @@ -138,13 +140,18 @@ "Finalizați" "Ați uitat parola?" "Redirecționați" + + "Redirecționați %1$d mesaj" + "Redirecționați %1$d mesaje" + "Redirecționați %1$d de mesaje" + "Înapoi" "Mergeți la roluri și permisiuni" "Mergeți la setări" "Ignorați" "Invitați" - "Invitați prieteni" - "Invitați prieteni în %1$s" + "Invitați persoane" + "Invitați persoane în %1$s" "Invitați persoane în %1$s" "Invitații" "Alăturați-vă" @@ -158,8 +165,8 @@ "Gestionați contul și dispozitivele" "Gestionați dispozitivele" "Gestionați camerele" - "Marcați ca citit" - "Contactați" + "Marcați ca citită" + "Trimiteți mesaj" "Minimizați" "Următorul" "Nu" @@ -169,11 +176,11 @@ "Setări" "Deschideți cu" "Fixați" - "Raspuns rapid" + "Răspuns rapid" "Citați" "Reacționați" "Respingeți" - "Indepărtați" + "Îndepărtați" "Ștergeți descrierea" "Ștergeți mesajul" "Răspundeți" @@ -198,12 +205,12 @@ "Partajați linkul" "Partajați locația în timp real" "Afișați" - "Autentificați-vă din nou" - "Deconectați-vă" - "Deconectați-vă oricum" + "Conectați-vă din nou" + "Eliminați acest dispozitiv" + "Eliminați acest dispozitiv oricum" "Omiteți" "Începeți" - "Începeți discuția" + "Începeți conversația" "Începeți din nou" "Începeți verificarea" "Atingeți pentru a încărca harta" @@ -212,11 +219,11 @@ "Atingeți pentru opțiuni" "Traduceți" "Încercați din nou" - "Defixați" + "Anulați fixarea" "Apel video" "Vizualizați" - "Vedeți în lista de mesaje" - "Vedeți sursă" + "Vedeți în cronologie" + "Vedeți sursa" "Apel vocal" "Da" "Da, încercați din nou" @@ -224,14 +231,16 @@ "Upgrade disponibil" "Despre" "Politică de utilizare rezonabilă" - "Adăugare cont" - "Adăugare cont" + "Setări cont" + "Adăugați un cont" + "Adăugați un cont" "Adăugare descriere" "Setări avansate" "o imagine" - "Analitice" + "Date analitice" "Ați părăsit camera" - "Ați fost deconectat din sesiune." + "Ați fost deconectat(ă) din sesiune." + "Setările aplicației" "Aspect" "Audio" "Beta" @@ -242,21 +251,23 @@ "Apel inițiat" "Ați respins un apel" "Backup conversații" + "Conversații" + "Bloc de cod" "Copiat în clipboard" "Drepturi de autor" "Se creează camera…" - "Se crează spațiul" + "Se creează spațiul" "Cerere anulată" - "Cameră părăsită" + "A ieșit din cameră" "Spațiu părăsit" "Invitația a fost refuzată" "Eroare de decriptare" "Descriere" - "Opțiuni programator" + "Opțiuni dezvoltatori" "ID-ul dispozitivului" - "Chat direct" - "Nu mai afișa acest mesaj" - "Descărcarea a eșuat" + "Conversație directă" + "Nu mai afișați acest mesaj" + "Nu s-a putut descărca" "Se descarcă" "(editat)" "Editare" @@ -268,61 +279,66 @@ "Se termină la %1$s" "Introduceți codul PIN" "Eroare" - "A apărut o eroare, este posibil să nu primiți notificări pentru mesaje noi. Vă rugăm să depanați notificările din setări. + "A apărut o eroare, este posibil să nu primiți notificări pentru mesaje noi. Depanați notificările din setări. -Motiv:%1$s." +Motiv: %1$s." "Toți" - "Eșuat" + "Nu a reușit" "Favorite" "Favorită" "Se sincronizează notificările…" - "Fişier" + "Fișier" "Fișier șters" "Fișier salvat" "Fișier salvat în Descărcări" "Redirecționați mesajul" + "Redirecționați către" "Utilizate frecvent" "Galerie" "%1$d atașament…" "%1$d atașamente…" - "%1$d atașamente…" + "%1$d de atașamente…" "%1$d element media…" "%1$d elemente media…" - "%1$d elemente media…" + "%1$d de elemente media…" "GIF" "Apel de grup în curs" + "Camere" "Imagine" "Ca răspuns la %1$s" "Instalați APK" - "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost trimisă." - "Se părăsește conversația" + "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu ajungă la destinatar." + "Se părăsește camera" "Se părăsește spațiul" - "Linie copiată în clipboard" + "Rând copiat în clipboard" "Linkul a fost copiat în clipboard" "Conectați un dispozitiv nou" "Locație în timp real" "Locația în timp real s-a încheiat" "Se încarcă…" - "Se încarcă…" + "Se încarcă mai multe…" + "Partajarea locației" "%d altul" "%d alții" "%d alții" + "Calitatea încărcării fișierelor media" - "%1$d Membru" - "%1$d Membri" - "%1$d Membri" + "%1$d membru" + "%1$d membri" + "%1$d de membri" "Mesaj" "Acțiuni mesaj" - "Mesajul nu a putut fi trimis" + "Mesajul dvs. nu a putut fi trimis" "Aspectul mesajelor" "Mesaj șters" + "Moderare și siguranță" "Modern" "Dezactivare sunet" "Nume" @@ -331,7 +347,7 @@ Motiv:%1$s." "Fără nume de cameră" "Fără nume de spațiu" "Necriptat" - "Deconectat" + "Deconectat(ă)" "Într-un apel" "Licențe open source" "sau" @@ -341,19 +357,19 @@ Motiv:%1$s." "Permalink" "Permisiune" "Fixat" - "Vă rugăm să verificați conexiunea la internet" - "Va rugam asteptati…" - "Sunteți sigur că doriți să încheiați acest sondaj?" - "Sondajul %1$s" + "Verificați conexiunea la internet" + "Vă rugăm să așteptați…" + "Sigur doriți să încheiați acest sondaj?" + "Sondaj: %1$s" "Sondaj" "Total voturi: %1$s" "Rezultatele vor fi afișate după încheierea sondajului" "%d vot" "%d voturi" - "%d voturi" + "%d de voturi" - "Se pregăteşte…" + "Se pregătește…" "Politica de confidențialitate" "Privat" "Cameră privată" @@ -361,16 +377,16 @@ Motiv:%1$s." "Public" "Cameră publică" "Spațiu public" - "Reacţie" + "Reacție" "Reacții" "Motiv" "Cheie de recuperare" - "Se actualizează" + "Se actualizează…" "Se elimină…" "%1$d răspuns" "%1$d răspunsuri" - "%1$d răspunsuri" + "%1$d de răspunsuri" "Răspuns pentru %1$s" "Raportați o problemă" @@ -379,11 +395,11 @@ Motiv:%1$s." "Rol" "Cameră" "Numele camerei" - "de exemplu, numele proiectului dumneavoastră" + "de exemplu, numele proiectului dvs." "%1$d Camera" "%1$d Camere" - "%1$d Camere" + "%1$d de Camere" "Modificări salvate" "Se salvează…" @@ -400,7 +416,7 @@ Motiv:%1$s." "Trimiteți către" "Se trimite…" - "Trimiterea a eșuat" + "Nu s-a putut trimite" "Trimis" ". " "Serverul nu este compatibil" @@ -412,22 +428,24 @@ Motiv:%1$s." "Locație în timp real partajată" "Locație partajată" "Spațiu comun" - "Eliminare în curs" + "Conectat(ă)" + "Se elimină dispozitivul" "Ceva nu a mers bine" - "Am întâmpinat o problemă. Vă rugăm să încercați din nou." + "Am întâmpinat o problemă. Încercați din nou." "Spațiu" "Membrii spațiului" "Despre ce este vorba în acest spațiu?" "%1$d Spațiu" "%1$d Spații" - "%1$d Spații" + "%1$d de Spații" "Se începe conversația…" - "Autocolant" + "Sticker" "Succes" "Sugerat" "Sugestii" + "Schimbați conturile" "Se sincronizează…" "Text" "Notificări despre software de la terți" @@ -438,10 +456,10 @@ Motiv:%1$s." "Nu s-a putut decripta" "Trimis de pe un dispozitiv nesigur" "Nu aveți acces la acest mesaj" - "Identitatea digitala verificată a expeditorului a fost resetată" + "Identitatea digitală verificată a expeditorului a fost resetată" "Nu am putut trimite invitații unuia sau mai multor utilizatori." - "Nu s-a putut trimite invitația (invitațiile)" - "Deblocare" + "Nu am putut trimite invitația (invitațiile)" + "Deblocați" "Activare sunet" "Apel incompatibil" "Eveniment neacceptat" @@ -449,7 +467,7 @@ Motiv:%1$s." "Utilizator" "Verificare anulată" "Verificare completă" - "Verificarea a eșuat" + "Nu s-a putut verifica" "Verificat" "Verificați dispozitivul" "Verificați identitatea" @@ -462,11 +480,12 @@ Motiv:%1$s." "Calitate standard" "Echilibru între calitate și viteza de încărcare" "Mesaj vocal" - "Se aşteaptă…" + "Se așteaptă…" "Mesaj în așteptare" - "Se așteptă localizarea în timp real…" + "Se așteaptă localizarea în timp real…" "Oricine poate vedea istoricul" "Dumneavoastră" + "Contul dvs." "Descărcarea nu este disponibilă." "Ceva nu a mers bine" "Se scanează…" @@ -475,53 +494,53 @@ Motiv:%1$s." "%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" - "Identitatea digitala lui %1$s a fost resetată. %2$s" - "Identitatea digitala %2$s a lui %1$s a fost resetată. %3$s" + "Identitatea digitală a lui %1$s a fost resetată. %2$s" + "Identitatea digitală %2$s a lui %1$s a fost resetată. %3$s" "(%1$s)" "Identitatea lui %1$s a fost resetată." "Identitatea digitală %2$s a lui %1$s a fost resetată. %3$s" "Retrageți verificarea" "Permiteți accesul" - "Linkul %1$s vă redirecționează către un alt site %2$s + "Linkul %1$s vă redirecționează către un alt site %2$s. -Sunteți sigur că doriți să continuați?" +Sigur doriți să continuați?" "Verificați din nou acest link" "Selectați calitatea implicită a videoclipurilor pe care le încărcați." "Calitatea încărcării videoclipurilor" "Dimensiunea maximă permisă pentru fișiere este: %1$s" "Dimensiunea fișierului este prea mare pentru a fi încărcat." "Cameră raportată" - "Camera a fost raportată si parasită" + "Camera a fost raportată și părăsită" "Confirmare" "Eroare" "Succes" "Avertisment" "Aveți modificări nesalvate." - "Modificările dumneavoastră nu au fost salvate. Sunteți sigur că doriți să vă întoarceți?" + "Modificările dvs. nu au fost salvate. Sigur doriți să reveniți?" "Salvați modificările?" "Dimensiunea maximă permisă pentru fișiere este: %1$s" "Selectați calitatea videoclipului pe care doriți să îl încărcați." "Selectați calitatea de încărcare a videoclipurilor" - "Sunteți deja conectat pe acest dispozitiv ca %1$s." - "Serverul dumneavoastră trebuie actualizat pentru a suporta serviciul de autentificare Matrix și crearea de conturi." - "Crearea permalink-ului a eșuat" - "%1$s nu a putut încărca harta. Vă rugăm să încercați din nou mai târziu." - "Încărcarea mesajelor a eșuat" - "%1$s nu a putut accesa locația dumneavoastră. Vă rugăm să încercați din nou mai târziu." - "Trimiterea mesajului vocal nu a reușit." + "Sunteți deja conectat(ă) pe acest dispozitiv ca %1$s." + "Furnizorul contului dvs. trebuie actualizat pentru a suporta serviciul de autentificare Matrix și crearea de conturi." + "Nu s-a putut crea permalinkul" + "%1$s nu a putut încărca harta. Încercați din nou mai târziu." + "Nu s-au putut încărca mesajele" + "%1$s nu a putut accesa locația dvs. Încercați din nou mai târziu." + "Încărcarea mesajului vocal nu a reușit." "Camera nu mai există sau invitația nu mai este valabilă." - "Vă rugăm să activați GPS-ul pentru a accesa funcțiile bazate pe locație." + "Activați GPS-ul pentru a accesa funcțiile bazate pe locație." "Mesajul nu a fost găsit" - "%1$s nu are permisiuni pentru a accesa locația dumneavoastră. Puteți permite accesul în Setări." - "%1$s nu are permisiuni pentru a accesa locația dumneavoastră. Permiteți accesul mai jos." + "%1$s nu are permisiuni pentru a vă accesa locația. Puteți permite accesul în Setări." + "%1$s nu are permisiuni pentru a accesa locația. Permiteți accesul mai jos." "%1$s nu are permisiunea de a vă accesa microfonul. Permiteți accesul pentru a înregistra un mesaj vocal." "Acest lucru se poate datora unor probleme de rețea sau de server." "Această adresă de cameră există deja. Încercați să editați câmpul adresei camerei sau să schimbați numele camerei." "Unele caractere nu sunt permise. Sunt acceptate doar literele, cifrele și următoarele simboluri ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" "Unele mesaje nu au fost trimise" "Ne pare rău, a apărut o eroare" - "🔐️ Alăturați-vă mie în %1$s" - "Hei, vorbește cu mine pe %1$s: %2$s" + "🔐️ Hai alături de mine în %1$s" + "Salut, vorbește cu mine pe %1$s: %2$s" "Partajarea locației în timp real" "Partajarea locației este în curs de desfășurare" "%1$s Locație în timp real" @@ -538,45 +557,56 @@ Sunteți sigur că doriți să continuați?" "%1$d persoană" "%1$d persoane" - "%1$d persoane" + "%1$d de 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." + "Homeserverul dvs. 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." + "Nu s-au putut selecta fișierele media, î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ță" "%1$d Mesaj fixat" "%1$d Mesaje fixate" - "%1$d Mesaje fixate" + "%1$d de Mesaje fixate" "Mesaje fixate" "Urmează să accesați contul dvs. %1$s pentru a vă reseta identitatea digitală. După aceea, veți fi redirecționat către aplicație." - "Nu puteți confirma? Accesați contul dumneavoastră pentru a vă reseta identitatea digitală." + "Nu puteți confirma? Accesați contul dvs. pentru a vă reseta identitatea digitală." "Retrageți verificarea și trimiteți" "Puteți să vă retrageți verificarea și să trimiteți acest mesaj oricum, sau puteți anula pentru moment și să încercați din nou mai târziu după reverificarea lui %1$s." - "Mesajul dumneavoastră nu a fost trimis deoarece identitatea digitala verificată a lui %1$s s-a schimbat" + "Mesajul dvs. nu a fost trimis deoarece identitatea digitală verificată a lui %1$s a fost resetată" "Trimiteți mesajul oricum" - "%1$s utilizează unul sau mai multe dispozitive neverificate. Puteți trimite mesajul oricum sau puteți anula pentru moment și puteți încerca din nou mai târziu, după ce %2$s își va verifica toate dispozitivele." - "Mesajul dvs. nu a fost trimis deoarece %1$s nu si-a verificat toate dispozitivele" - "Unul sau mai multe dispozitive nu sunt verificate. Puteți trimite mesajul oricum sau puteți anula deocamdată și încercați din nou mai târziu după ce ați verificat toate dispozitivele." - "Mesajul dumneavoastră nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive" + "%1$s utilizează unul sau mai multe dispozitive neverificate. Puteți trimite mesajul oricum sau puteți anula pentru moment și puteți încerca din nou mai târziu, după ce %2$s își verifică toate dispozitivele." + "Mesajul dvs. nu a fost trimis deoarece %1$s nu și-a verificat toate dispozitivele" + "Unul sau mai multe dispozitive nu sunt verificate. Puteți trimite mesajul oricum sau puteți anula deocamdată și să încercați din nou mai târziu după ce ați verificat toate dispozitivele." + "Mesajul dvs. nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive" "Editați administratorii sau proprietarii" - "Procesarea datelor media a eșuat, vă rugăm să încercați din nou." + "Nu s-au putut procesa fișierele media, încercați din nou." "Nu am putut găsi detaliile utilizatorului" "Mesaj în %1$s" "Extindeți" "Reduceți" "Se partajează locația în timp real" + "Ați selectat numărul maxim de mesaje" + + "%1$d mesaj redirecționat" + "%1$d mesaje redirecționate" + "%1$d de mesaje redirecționate" + "Deja vizualizați această cameră!" "%1$s din %2$s" "%1$s Mesaje fixate" "Se încarcă mesajul…" "Vedeți toate" - "Chat" + + "%1$d mesaj selectat" + "%1$d mesaje selectate" + "%1$d de mesaje selectate" + + "Conversație" "Partajați locația" - "Distribuiți locația mea" + "Partajați locația mea" "Deschideți în Apple Maps" "Deschideți în Google Maps" "Deschideți în OpenStreetMap" @@ -594,17 +624,17 @@ Sunteți sigur că doriți să continuați?" "%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 identitatea digitală verificată a lui %1$s a fost resetată." + "Mesajul nu a fost trimis deoarece %1$s nu și-a verificat toate dispozitivele." "Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive." "Locație" "Versiunea: %1$s (%2$s)" "ro" - "Messaje anterioare nu sunt disponibile pe acest dispozitiv." + "Mesajele anterioare nu sunt disponibile pe acest dispozitiv." "Trebuie să verificați acest dispozitiv pentru a avea acces la mesajele anterioare." "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ă" + "Acest mesaj a fost blocat. Fie nu ați verificat dispozitivul, fie expeditorul trebuie să vă verifice identitatea digitală." + "Dispozitivul dvs. este prea vechi, este necesar un dispozitiv cu Android 8 sau o versiune mai nouă." + "Afișarea hărților nu este acceptată" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 8c3599a320..7279b906c0 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -111,6 +111,7 @@ "Скопировать ссылку" "Скопировать ссылку на сообщение" "Копировать текст" + "Скопировать в буфер обмена" "Создать" "Создать комнату" "Создать пространство" @@ -189,7 +190,6 @@ "Повторите расшифровку" "Сохранить" "Поиск" - "Выбрать" "Выбрать все" "Отправить" "Отправить отредактированное сообщение" @@ -225,6 +225,7 @@ "Доступно обновление" "О приложении" "Политика допустимого использования" + "Настройки аккаунта" "Добавить аккаунт" "Добавить другой аккаунт" "Добавление подписи" @@ -233,6 +234,7 @@ "Аналитика" "Вы покинули комнату" "Вы вышли из сессии" + "Настройки приложения" "Внешний вид" "Аудио" "Бета-версия" @@ -243,6 +245,7 @@ "Звонок начат" "Ты отклонил звонок" "Резервная копия чатов" + "Блок кода" "Скопировано в буфер обмена" "Авторское право" "Создание комнаты…" @@ -309,11 +312,13 @@ "Трансляция местоположения завершена" "Загрузка…" "Загрузить больше…" + "Обмен местоположением" "%d" "%d другие" "%d другие" + "Качество загружаемого медиа" "%1$d участник" "%1$d участников" @@ -324,6 +329,7 @@ "Ошибка отправки сообщения" "Оформление сообщения" "Сообщение удалено" + "Модерация и безопасность" "Современный" "Выкл. звук" "Имя" @@ -429,6 +435,7 @@ "Успешно" "Рекомендуемые" "Предложения" + "Сменить аккаунт" "Синхронизация" "Текст" "Уведомления о третьих лицах" @@ -468,6 +475,7 @@ "Ожидание трансляции местоположения…" "Любой может видеть историю" "Вы" + "Ваш аккаунт" "Загрузка недоступна." "Файл не найден" "Сканирование…" 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 fbb21a5ab9..9c4afb7759 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -101,6 +101,7 @@ "Вибрати файл" "Вибрати фото" "Очистити" + "Очистити все" "Закрити" "Завершити звіряння" "Підтвердити" @@ -111,6 +112,7 @@ "Скопіювати посилання" "Скопіювати посилання на повідомлення" "Скопіювати текст" + "Копіювати в буфер обміну" "Створити" "Створити кімнату" "Створити простір" @@ -138,6 +140,11 @@ "Завершити" "Забули пароль?" "Переслати" + + "Переслати %1$d повідомлення" + "Переслати %1$d повідомлення" + "Переслати %1$d повідомлень" + "Повернутися" "Перейти до ролей і дозволів" "Перейти до налаштувань" @@ -189,7 +196,6 @@ "Повторити спробу розшифрування" "Зберегти" "Шукати" - "Вибрати" "Вибрати все" "Надіслати" "Надіслати змінене повідомлення" @@ -225,6 +231,7 @@ "Доступне оновлення" "Відомості" "Політика прийнятного використання" + "Налаштування облікового запису" "Додати обліковий запис" "Додати ще один обліковий запис" "Додавання підпису" @@ -233,6 +240,7 @@ "Аналітика" "Ви вийшли з кімнати" "Ви вийшли з сеансу" + "Налаштування застосунку" "Вигляд" "Аудіо" "Бета-версія" @@ -243,6 +251,8 @@ "Виклик розпочато" "Ви відхилили виклик" "Резервне копіювання бесіди" + "Бесіди" + "Блок коду" "Скопійовано до буферу обміну" "Авторське право" "Створення кімнати…" @@ -282,6 +292,7 @@ "Файл збережено" "Файл збережений у Завантаження" "Переслати повідомлення" + "Переслати" "Частовживані" "Галерея" @@ -296,6 +307,7 @@ "GIF" "Триває груповий виклик" + "Кімнати" "Зображення" "У відповідь на %1$s" "Встановити APK" @@ -309,11 +321,13 @@ "Показ місцеперебування наживо завершено" "Завантаження" "Завантаження наступних…" + "Обмін даними про розташування" "%d інший" "%d інші" "%d інші" + "Якість вивантажуваних медіа" "%1$d учасник" "%1$d учасники" @@ -324,11 +338,13 @@ "Не вдалося надіслати повідомлення" "Макет повідомлень" "Повідомлення вилучено" + "Модерування та безпека" "Модерн" "Вимкнути звук" "Назва" "%1$s (%2$s)" "Немає результатів" + "Не знайдено результатів за запитом «%1$s». Спробуйте ввести інший пошуковий термін." "Немає назви кімнати" "Без назви простору" "Не зашифровано" @@ -376,6 +392,7 @@ "Відповідь %1$s" "Повідомити про проблему" "Звіт подано" + "Результати для запиту «%1$s»" "Багатоформатний текстовий редактор" "Роль" "Кімната" @@ -413,6 +430,7 @@ "Поділитися місцезнаходженням в реальному часі" "Поширене розташування" "Простір спільного користування" + "Вхід виконано" "Вилучення пристрою" "Щось пішло не так" "Ми зіткнулися з проблемою. Будь ласка, повторіть спробу." @@ -429,6 +447,7 @@ "Успіх" "Запропоновано" "Пропозиції" + "Змінити обліковий запис" "Синхронізація" "Текст" "Повідомлення третіх сторін" @@ -468,6 +487,7 @@ "Очікування геолокації…" "Будь-хто може переглянути історію" "Ви" + "Ваш обліковий запис" "Завантаження недоступне." "Файл не знайдено" "Сканування…" @@ -553,6 +573,14 @@ "%1$d Закріплених повідомлення" "Закріплені повідомлення" + "Ігнорувати оптимізацію заряду акумулятора" + "Залишати активним у фоновому режимі" + "Прихований режим" + "Вимкнути звук усіх каналів" + "Показувати на екрані блокування" + "Доступ з екрана блокування" + "Увімкнути системне накладання" + "Доступ до рухомої кнопки дій" "Ви збираєтеся перейти до свого облікового запису %1$s, щоб скинути свою цифрову ідентичність. Після цього ви повернетеся до застосунку." "Не можете підтвердити? Перейдіть до свого облікового запису, щоб скинути цифрову ідентичність." "Відкликати верифікацію та відправити" @@ -571,6 +599,11 @@ "Згорнути" "Спільний доступ до місцезнаходження у реальному часі" "Вибрано максимальну кількість повідомлень" + + "Переслано %1$d повідомлення" + "Переслано %1$d повідомлення" + "Переслано %1$d повідомлень" + "Уже переглядаєте цю кімнату!" "%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 b94aa76df5..8a71ad7241 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 @@ -105,6 +105,7 @@ "複製連結" "複製訊息連結" "複製文字" + "複製到剪貼簿" "建立" "建立聊天室" "建立空間" @@ -218,6 +219,7 @@ "可升級" "關於" "可接受使用政策" + "帳號設定" "新增帳號" "新增其他帳號" "新增標題" @@ -226,6 +228,7 @@ "分析" "您離開聊天室" "您已登出工作階段" + "應用程式設定" "外觀" "音訊" "測試版" @@ -236,6 +239,7 @@ "開始通話" "您拒絕了通話" "聊天室備份" + "程式碼區塊" "已複製到剪貼簿" "著作權" "正在建立聊天室…" @@ -298,9 +302,11 @@ "即時位置已結束" "載入中…" "載入更多……" + "位置分享" "其他 %d 個人" + "媒體上傳品質" "%1$d 位成員" @@ -309,6 +315,7 @@ "訊息傳送失敗" "訊息佈局" "訊息已移除" + "內容審核與安全" "現代" "關閉通知" "名稱" @@ -404,6 +411,7 @@ "成功" "已建議" "建議" + "切換帳號" "同步中" "文字" "第三方通知" @@ -443,6 +451,7 @@ "正在等待即時位置……" "任何人都可以檢視歷史" "您" + "您的帳號" "無法下載。" "發生錯誤" "正在掃描⋯⋯" @@ -541,11 +550,15 @@ "展開" "減少" "分享即時位置" + "已選取最多訊息" "已檢視此聊天室!" "第 %1$s 個,共 %2$s 個" "%1$s 個釘選訊息" "正在載入訊息……" "檢視全部" + + "已選取 %1$d 則訊息" + "聊天" "分享位置" "分享我的位置" 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 5ce3196189..27f282db2b 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -95,6 +95,7 @@ "选择文件" "选择照片" "清除" + "全部清除" "关闭" "完成验证" "确认" @@ -105,6 +106,7 @@ "复制链接" "复制消息链接" "复制文本" + "复制到剪贴板" "创建" "创建房间" "创建空间" @@ -132,6 +134,9 @@ "完成" "忘记密码?" "转发" + + "转发 %1$d 条消息" + "返回" "前往角色与权限" "前往设置" @@ -183,7 +188,6 @@ "重试解密" "保存" "搜索" - "选择" "全选" "发送" "发送编辑后的消息" @@ -219,6 +223,7 @@ "有可用升级" "关于" "可接受的使用政策" + "账户设置" "添加账户" "添加账户" "正在添加标题" @@ -227,6 +232,7 @@ "分析" "你离开了房间" "你已注销会话" + "应用设置" "外观" "音频" "Beta" @@ -237,6 +243,8 @@ "通话已开始" "你已拒接来电" "聊天记录备份" + "聊天" + "代码块" "已复制到剪贴板" "版权" "正在创建房间…" @@ -276,6 +284,7 @@ "文件已保存" "文件已保存到“下载”" "转发消息" + "转发到" "常用" "媒体组" @@ -286,6 +295,7 @@ "GIF" "群组通话进行中" + "房间" "图片" "回复 %1$s" "安装 APK" @@ -299,9 +309,11 @@ "实时位置已结束" "正在加载…" "正在加载更多…" + "位置共享" "其他 %d 人" + "媒体上传质量" "%1$d 个成员" @@ -310,11 +322,13 @@ "消息发送失败" "消息布局" "消息已移除" + "尺度与安全" "现代" "静音" "名称" "%1$s (%2$s)" "没有结果" + "未找到与“%1$s”相关的搜索结果。尝试其它搜索条件。" "无房间名称" "未命名空间" "未加密" @@ -340,7 +354,7 @@ "正在准备…" "隐私政策" - "私密" + "私有" "私有房间" "私有空间" "公共" @@ -358,6 +372,7 @@ "正在回复 %1$s" "报告问题" "报告已提交" + "与“%1$s”相关的搜索结果" "富文本编辑器" "角色" "房间" @@ -391,6 +406,7 @@ "已共享实时位置" "共享位置" "已共享的空间" + "已登录" "正在移除设备" "发生了一些错误" "我们遇到了一个问题。请重试。" @@ -445,6 +461,7 @@ "正在等待实时位置…" "任何人都可以看到历史" "你" + "你的账户" "无法下载。" "文件未找到" "正在扫描…" @@ -526,6 +543,14 @@ "%1$d 条已置顶的消息" "已置顶的消息" + "忽略电池优化" + "保持后台活跃" + "隐蔽模式" + "静默所有频道" + "在锁屏上显示" + "从锁屏访问" + "启用系统叠加层" + "访问浮动操作按钮" "你即将被重定向到你在 %1$s 上的账户以重置数字身份。之后将被带回 app。" "无法确认?请转到你的账户重置数字身份。" "撤回验证并发送" @@ -544,6 +569,9 @@ "折叠" "正在共享实时位置" "选择的消息已达到最大数量" + + "已转发 %1$d 条消息" + "已经位于此房间!" "%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 1b89d51482..08c56be768 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -98,6 +98,7 @@ "Choose file" "Choose photo" "Clear" + "Clear all" "Close" "Complete verification" "Confirm" @@ -136,6 +137,10 @@ "Finish" "Forgot password?" "Forward" + + "Forward %1$d message" + "Forward %1$d messages" + "Go back" "Go to roles & permissions" "Go to settings" @@ -187,7 +192,6 @@ "Retry decryption" "Save" "Search" - "Select" "Select all" "Send" "Send edited message" @@ -243,6 +247,7 @@ "Call started" "You declined a call" "Chat backup" + "Chats" "Code block" "Copied to clipboard" "Copyright" @@ -283,6 +288,7 @@ Reason: %1$s." "File saved" "File saved to Downloads" "Forward message" + "Forward to" "Frequently used" "Gallery" @@ -295,6 +301,7 @@ Reason: %1$s." "GIF" "Group call in progress" + "Rooms" "Image" "In reply to %1$s" "Install APK" @@ -329,6 +336,7 @@ Reason: %1$s." "Name" "%1$s (%2$s)" "No results" + "There are no results for “%1$s”. Try a new search term." "No room name" "No space name" "Not encrypted" @@ -374,6 +382,7 @@ Reason: %1$s." "Replying to %1$s" "Report a problem" "Report submitted" + "Results for “%1$s”" "Rich text editor" "Role" "Room" @@ -409,6 +418,7 @@ Reason: %1$s." "Shared live location" "Shared location" "Shared space" + "Signed in" "Removing device" "Something went wrong" "We encountered an issue. Please try again." @@ -548,6 +558,14 @@ Are you sure you want to continue?" "%1$d Pinned messages" "Pinned messages" + "Ignore battery optimisation" + "Stay active in background" + "Covert mode" + "Mute all channels" + "Show on lock screen" + "Access from the lock screen" + "Enable system overlay" + "Access a floating action button" "You\'re about to go to your %1$s account to reset your digital identity. Afterwards you\'ll be taken back to the app." "Can\'t confirm? Go to your account to reset your digital identity." "Withdraw verification and send" @@ -566,6 +584,10 @@ Are you sure you want to continue?" "Reduce" "Sharing live location" "Maximum messages selected" + + "%1$d message forwarded" + "%1$d messages forwarded" + "Already viewing this room!" "%1$s of %2$s" "%1$s Pinned messages" diff --git a/libraries/usersearch/impl/build.gradle.kts b/libraries/usersearch/impl/build.gradle.kts index bd8eb47cd1..897f63da12 100644 --- a/libraries/usersearch/impl/build.gradle.kts +++ b/libraries/usersearch/impl/build.gradle.kts @@ -20,7 +20,6 @@ android { setupDependencyInjection() dependencies { - implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.di) implementation(projects.libraries.matrixui) diff --git a/libraries/usersearch/test/build.gradle.kts b/libraries/usersearch/test/build.gradle.kts index 21cc140a7d..6899ba8c9c 100644 --- a/libraries/usersearch/test/build.gradle.kts +++ b/libraries/usersearch/test/build.gradle.kts @@ -11,12 +11,11 @@ plugins { } android { - namespace = "io.element.android.libraries.usersearch" + namespace = "io.element.android.libraries.usersearch.test" } dependencies { implementation(projects.libraries.architecture) - implementation(projects.libraries.matrixui) implementation(projects.libraries.matrix.api) api(projects.libraries.usersearch.api) } diff --git a/libraries/voiceplayer/api/build.gradle.kts b/libraries/voiceplayer/api/build.gradle.kts index e058210b7d..604fd3f5a2 100644 --- a/libraries/voiceplayer/api/build.gradle.kts +++ b/libraries/voiceplayer/api/build.gradle.kts @@ -14,8 +14,6 @@ android { } dependencies { - implementation(libs.androidx.annotationjvm) - implementation(libs.coroutines.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) } diff --git a/libraries/voiceplayer/impl/build.gradle.kts b/libraries/voiceplayer/impl/build.gradle.kts index 8fe79fb774..1e0040d98e 100644 --- a/libraries/voiceplayer/impl/build.gradle.kts +++ b/libraries/voiceplayer/impl/build.gradle.kts @@ -22,7 +22,6 @@ dependencies { api(projects.libraries.voiceplayer.api) implementation(projects.libraries.architecture) - implementation(projects.libraries.audio.api) implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) @@ -31,7 +30,6 @@ dependencies { implementation(projects.libraries.uiUtils) implementation(projects.services.analytics.api) - implementation(libs.androidx.annotationjvm) implementation(libs.androidx.datastore.preferences) implementation(libs.coroutines.core) 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 070b341d76..62f91d3ccf 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 @@ -17,13 +17,13 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.voicerecorder.api.VoiceRecorderState import io.element.android.libraries.voicerecorder.impl.audio.Audio import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig +import io.element.android.libraries.voicerecorder.impl.audio.FakeAudioLevelCalculator +import io.element.android.libraries.voicerecorder.impl.audio.FakeAudioReaderFactory +import io.element.android.libraries.voicerecorder.impl.audio.FakeEncoder import io.element.android.libraries.voicerecorder.impl.audio.SampleRate import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderBindingContainer -import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator -import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory -import io.element.android.libraries.voicerecorder.test.FakeEncoder -import io.element.android.libraries.voicerecorder.test.FakeFileSystem -import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager +import io.element.android.libraries.voicerecorder.impl.file.FakeFileSystem +import io.element.android.libraries.voicerecorder.impl.file.FakeVoiceFileManager import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.test.TestScope diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt similarity index 77% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt index 48e12c3192..583d36fdfd 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioLevelCalculator.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioLevelCalculator.kt @@ -6,9 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator import kotlin.math.abs class FakeAudioLevelCalculator : AudioLevelCalculator { diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt similarity index 85% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt index b14bb400e5..e946abecd8 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReader.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReader.kt @@ -6,11 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.voicerecorder.impl.audio.Audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioReader import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import kotlinx.coroutines.yield 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/impl/audio/FakeAudioReaderFactory.kt similarity index 70% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/audio/FakeAudioReaderFactory.kt index d3e434538e..2b5a998a90 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/impl/audio/FakeAudioReaderFactory.kt @@ -6,12 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.voicerecorder.test +package io.element.android.libraries.voicerecorder.impl.audio import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.voicerecorder.impl.audio.Audio -import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig -import io.element.android.libraries.voicerecorder.impl.audio.AudioReader class FakeAudioReaderFactory( private val audio: List